mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(llm): RFC-009 PR-0b — migrate DashScope + Anthropic helpers out of AgentGraphBuilder
PR-0 only installed the strategy seam; the actual ~600 LOC of provider-
specific construction stayed in AgentGraphBuilder as transitional public
helpers. PR-0b moves the DashScope + Anthropic halves into their builders
proper. (OpenAI larger refactor — 5 sub-helpers including Kimi/o-series
special cases — is left for a follow-up PR-0c.)
AgentDashScopeChatModelBuilder now owns:
- buildDashScopeApi (with provider/env/reflection key+url fallback chain)
- buildDashScopeOptions (model/temp/max-tokens/topP + built-in search)
- normalizeDashScopeBaseUrl (strip /compatible-mode/, return null for SDK default)
- readApiKeyFromDefaultChatModel + readBaseUrlFromDefaultChatModel +
readDashScopeApiFromDefaultChatModel (reflection-based final fallback)
- isBuiltinSearchEnabled (renamed from isDashScopeSearchEnabled, called
by AgentGraphBuilder.build via the now-injected dashScopeBuilder ref)
AgentAnthropicChatModelBuilder now owns:
- buildAnthropicApi (key validation, applyHttpTimeouts duplicated locally)
- buildAnthropicOptions (extended-thinking budget mapping low/medium/high/max
→ 4k/8k/16k/32k, temperature=1 enforcement, RFC-014 prompt cache options)
AgentGraphBuilder dropped:
- DashScope: ~120 LOC (api + options + 4 helpers + isDashScopeSearchEnabled)
- Anthropic: ~75 LOC (api + options)
- DashScopeChatModel + DashScopeConnectionProperties fields (unused after move)
- Deprecated single-fallback buildFallbackModel (no callers, superseded
by buildFallbackChain since RFC-009 PR-1)
- 5 imports for moved DashScope/Anthropic types
Net: -154 LOC in AgentGraphBuilder (1721 → 1567), +372 across the two new
builders. Strategy seam is now real for 3 of 4 protocols (ChatGPT was
already standalone, OpenAI is PR-0c). 220/220 tests still green — no
behavior change.
This commit is contained in:
parent
3d213eb281
commit
6c15622b59
@ -1,10 +1,6 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
// PR-0b: DashScope imports moved with the construction code into AgentDashScopeChatModelBuilder.
|
||||
import com.alibaba.cloud.ai.graph.CompiledGraph;
|
||||
import com.alibaba.cloud.ai.graph.CompileConfig;
|
||||
import com.alibaba.cloud.ai.graph.KeyStrategy;
|
||||
@ -16,9 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||
// PR-0b: Anthropic imports moved with the construction code into AgentAnthropicChatModelBuilder.
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
@ -110,8 +104,7 @@ public class AgentGraphBuilder {
|
||||
private final ApprovalWorkflowService approvalService;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final DashScopeChatModel dashScopeChatModel;
|
||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||
// PR-0b: dashScopeChatModel + dashScopeConnectionProperties live on AgentDashScopeChatModelBuilder now.
|
||||
private final RetryTemplate retryTemplate;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
@ -133,6 +126,8 @@ public class AgentGraphBuilder {
|
||||
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
|
||||
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
|
||||
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
|
||||
/** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */
|
||||
private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder;
|
||||
|
||||
/**
|
||||
* 根据 AgentEntity 构建完整的 Agent 实例
|
||||
@ -168,7 +163,7 @@ public class AgentGraphBuilder {
|
||||
boolean builtinSearchEnabled = false;
|
||||
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
||||
builtinSearchEnabled = isDashScopeSearchEnabled(runtimeModel, provider);
|
||||
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
|
||||
} else if (isKimiProvider(provider) && Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
|
||||
builtinSearchEnabled = true;
|
||||
}
|
||||
@ -674,48 +669,8 @@ public class AgentGraphBuilder {
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use {@link #buildFallbackChain(ModelConfigEntity)} — the
|
||||
* single-fallback variant cannot represent an ordered chain and only
|
||||
* worked when the primary was a non-DashScope provider.
|
||||
*/
|
||||
@Deprecated
|
||||
ChatModel buildFallbackModel(ChatModel primaryModel) {
|
||||
try {
|
||||
ModelProviderEntity dashScopeProvider = modelProviderService.getProviderConfig("dashscope");
|
||||
DashScopeApi api = buildDashScopeApi(dashScopeProvider);
|
||||
ModelConfigEntity fallbackModelConfig = modelConfigService.getDefaultModelByProvider("dashscope");
|
||||
DashScopeChatOptions options = buildDashScopeOptions(
|
||||
fallbackModelConfig != null ? fallbackModelConfig : modelConfigService.getDefaultModel(), dashScopeProvider);
|
||||
ChatModel fallback = dashScopeChatModel.mutate()
|
||||
.dashScopeApi(api)
|
||||
.defaultOptions(options)
|
||||
.build();
|
||||
return (fallback != primaryModel) ? fallback : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("无法构建 DashScope fallback 模型(UI 配置和环境变量均无可用 key),将跳过 fallback: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 DashScope 内置搜索是否开启:默认开启,仅当显式设为 false 时关闭
|
||||
*/
|
||||
private boolean isDashScopeSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
// provider generateKwargs 中的 enableSearch 优先级最高(UI 开关直接控制)
|
||||
Object kwargsSearch = kwargs.get("enableSearch");
|
||||
if (kwargsSearch != null) {
|
||||
return Boolean.TRUE.equals(kwargsSearch);
|
||||
}
|
||||
// model 级别字段:null 视为未设置(DashScope 默认开启),false 视为显式关闭
|
||||
if (Boolean.FALSE.equals(runtimeModel.getEnableSearch())) {
|
||||
// DB DEFAULT FALSE 导致已有行为 false,此时如果是 DashScope 仍默认开启
|
||||
// 只有用户手动设置过才会有明确含义,但目前无法区分,所以 DashScope 默认开启
|
||||
return true;
|
||||
}
|
||||
return true; // DashScope 默认开启
|
||||
}
|
||||
// PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers).
|
||||
// PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder.
|
||||
|
||||
// ==================== Prompt 构建 ====================
|
||||
|
||||
@ -844,42 +799,9 @@ public class AgentGraphBuilder {
|
||||
|
||||
// ==================== 模型选项构建 ====================
|
||||
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
|
||||
public DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
// PR-0b: buildDashScopeOptions moved to AgentDashScopeChatModelBuilder
|
||||
|
||||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||
builder.withModel(runtimeModel.getModelName());
|
||||
}
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.withTemperature(runtimeModel.getTemperature());
|
||||
}
|
||||
if (runtimeModel.getMaxTokens() != null) {
|
||||
builder.withMaxToken(runtimeModel.getMaxTokens());
|
||||
}
|
||||
if (runtimeModel.getTopP() != null) {
|
||||
builder.withTopP(runtimeModel.getTopP());
|
||||
}
|
||||
// 内置搜索:复用统一判断方法
|
||||
if (isDashScopeSearchEnabled(runtimeModel, provider)) {
|
||||
builder.withEnableSearch(true);
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
}
|
||||
if (StringUtils.hasText(strategy)) {
|
||||
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||||
.searchStrategy(strategy)
|
||||
.enableSource(true)
|
||||
.enableCitation(true)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0c (OpenAI). */
|
||||
public OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder();
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
@ -1055,119 +977,11 @@ public class AgentGraphBuilder {
|
||||
|
||||
// ==================== DashScope API 构建 ====================
|
||||
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
|
||||
public DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
|
||||
DashScopeApi.Builder builder = DashScopeApi.builder();
|
||||
|
||||
// API Key 回落链:provider UI 配置 → 环境变量/application.yml → 默认 bean 反射
|
||||
String apiKey = provider != null ? provider.getApiKey() : null;
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = dashScopeConnectionProperties.getApiKey();
|
||||
}
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = readApiKeyFromDefaultChatModel();
|
||||
}
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.dashscope_key_missing", "DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key,或设置 DASHSCOPE_API_KEY 环境变量");
|
||||
}
|
||||
builder.apiKey(apiKey.trim());
|
||||
|
||||
// Base URL 回落链:provider UI 配置 → 环境变量/application.yml → 默认 bean 反射
|
||||
String baseUrl = provider != null ? provider.getBaseUrl() : null;
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||||
}
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = readBaseUrlFromDefaultChatModel();
|
||||
}
|
||||
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
|
||||
if (StringUtils.hasText(normalizedBaseUrl)) {
|
||||
builder.baseUrl(normalizedBaseUrl);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
// PR-0b: buildDashScopeApi moved to AgentDashScopeChatModelBuilder
|
||||
|
||||
// ==================== Anthropic API 构建 ====================
|
||||
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
|
||||
public AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
|
||||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||||
throw new MateClawException("err.agent.anthropic_not_configured", "Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||
}
|
||||
String apiKey = provider.getApiKey();
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.anthropic_key_invalid", "Anthropic API Key 未配置或无效: " + provider.getProviderId());
|
||||
}
|
||||
String baseUrl = provider.getBaseUrl();
|
||||
RestClient.Builder restClientBuilder = applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder));
|
||||
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
|
||||
|
||||
AnthropicApi.Builder builder = AnthropicApi.builder()
|
||||
.apiKey(apiKey.trim())
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder);
|
||||
if (StringUtils.hasText(baseUrl)) {
|
||||
builder.baseUrl(baseUrl.trim());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
|
||||
public AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||
builder.model(runtimeModel.getModelName());
|
||||
}
|
||||
|
||||
// Extended thinking: 通过 ThinkingLevelHolder 获取请求级思考深度
|
||||
String thinkingLevel = ThinkingLevelHolder.get();
|
||||
boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||
|
||||
if (thinkingEnabled) {
|
||||
// Anthropic thinking 模式下:temperature 必须为 1,不能设 top_p
|
||||
// budget_tokens 根据级别映射
|
||||
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
|
||||
case "low" -> 4096;
|
||||
case "medium" -> 8192;
|
||||
case "high" -> 16384;
|
||||
case "max" -> 32768;
|
||||
default -> 16384;
|
||||
};
|
||||
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
|
||||
// Thinking 模式要求 max_tokens 足够大(含 thinking tokens)
|
||||
builder.maxTokens(Math.max(budgetTokens + 4096,
|
||||
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
|
||||
// Anthropic thinking 模式要求 temperature=1
|
||||
builder.temperature(1.0);
|
||||
} else {
|
||||
// 非 thinking 模式:正常设置参数
|
||||
// Anthropic API does not allow temperature and top_p to be specified simultaneously.
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.temperature(runtimeModel.getTemperature());
|
||||
} else if (runtimeModel.getTopP() != null) {
|
||||
builder.topP(runtimeModel.getTopP());
|
||||
}
|
||||
// RFC-025 Change 5: 非正值 maxTokens 会被 Anthropic API 直接拒绝;本地提前拦截
|
||||
// 并 fallback 到 4096,避免错误信息在运行时才暴露、也防止坏配置透传
|
||||
Integer configuredMax = runtimeModel.getMaxTokens();
|
||||
if (configuredMax != null && configuredMax > 0) {
|
||||
builder.maxTokens(configuredMax);
|
||||
} else {
|
||||
if (configuredMax != null) {
|
||||
log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096",
|
||||
configuredMax, runtimeModel.getModelName());
|
||||
}
|
||||
builder.maxTokens(4096);
|
||||
}
|
||||
}
|
||||
// RFC-014: 接入 Anthropic prompt caching(spring-ai 1.1.4 一等支持)
|
||||
// 通过 cacheOptions 配置 system / tools / conversation history 自动打 cache_control,
|
||||
// 多轮对话场景可节省 50–75% 输入 token 成本。
|
||||
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||
|
||||
return builder.internalToolExecutionEnabled(false).build();
|
||||
}
|
||||
// PR-0b: buildAnthropicApi + buildAnthropicOptions moved to AgentAnthropicChatModelBuilder
|
||||
|
||||
// ==================== 参数解析辅助方法 ====================
|
||||
|
||||
@ -1283,25 +1097,7 @@ public class AgentGraphBuilder {
|
||||
|
||||
// ==================== URL 规范化 ====================
|
||||
|
||||
private String normalizeDashScopeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = baseUrl.trim();
|
||||
// 去掉 OpenAI 兼容模式路径(用户可能从兼容模式 URL 迁移过来)
|
||||
int compatibleIndex = normalized.indexOf("/compatible-mode/");
|
||||
if (compatibleIndex >= 0) {
|
||||
normalized = normalized.substring(0, compatibleIndex);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
// 如果结果是 DashScope 默认地址,返回 null 让 SDK 使用内置默认值,避免路径拼接问题
|
||||
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
// PR-0b: normalizeDashScopeBaseUrl moved to AgentDashScopeChatModelBuilder
|
||||
|
||||
private String normalizeOpenAiBaseUrl(String baseUrl) {
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
@ -1406,48 +1202,8 @@ public class AgentGraphBuilder {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 反射读取默认模型配置 ====================
|
||||
|
||||
private String readApiKeyFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) {
|
||||
return null;
|
||||
}
|
||||
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
|
||||
apiKeyField.setAccessible(true);
|
||||
Object apiKey = apiKeyField.get(api);
|
||||
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
|
||||
return key.getValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readBaseUrlFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) {
|
||||
return null;
|
||||
}
|
||||
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
|
||||
baseUrlField.setAccessible(true);
|
||||
Object baseUrl = baseUrlField.get(api);
|
||||
return baseUrl instanceof String value ? value : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||||
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||||
apiField.setAccessible(true);
|
||||
Object api = apiField.get(dashScopeChatModel);
|
||||
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
||||
}
|
||||
// PR-0b: reflection helpers (readApiKey/BaseUrl/DashScopeApiFromDefaultChatModel)
|
||||
// moved to AgentDashScopeChatModelBuilder
|
||||
|
||||
// ==================== 日志辅助 ====================
|
||||
|
||||
|
||||
@ -1,35 +1,59 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
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;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Thin strategy adapter for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
|
||||
* See {@link AgentDashScopeChatModelBuilder} for the delegate-pattern rationale.
|
||||
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
|
||||
*
|
||||
* <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>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
|
||||
|
||||
public AgentAnthropicChatModelBuilder(
|
||||
@Lazy AgentGraphBuilder agentGraphBuilder,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.agentGraphBuilder = agentGraphBuilder;
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||
AnthropicCacheOptionsFactory anthropicCacheOptionsFactory) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
this.anthropicCacheOptionsFactory = anthropicCacheOptionsFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -39,8 +63,8 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
AnthropicApi api = agentGraphBuilder.buildAnthropicApi(provider);
|
||||
AnthropicChatOptions options = agentGraphBuilder.buildAnthropicOptions(model);
|
||||
AnthropicApi api = buildAnthropicApi(provider);
|
||||
AnthropicChatOptions options = buildAnthropicOptions(model);
|
||||
return AnthropicChatModel.builder()
|
||||
.anthropicApi(api)
|
||||
.defaultOptions(options)
|
||||
@ -48,4 +72,93 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
}
|
||||
|
||||
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
|
||||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||||
throw new MateClawException("err.agent.anthropic_not_configured",
|
||||
"Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||
}
|
||||
String apiKey = provider.getApiKey();
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.anthropic_key_invalid",
|
||||
"Anthropic API Key 未配置或无效: " + provider.getProviderId());
|
||||
}
|
||||
String baseUrl = provider.getBaseUrl();
|
||||
RestClient.Builder restClientBuilder = applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder));
|
||||
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
|
||||
|
||||
AnthropicApi.Builder builder = AnthropicApi.builder()
|
||||
.apiKey(apiKey.trim())
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder);
|
||||
if (StringUtils.hasText(baseUrl)) {
|
||||
builder.baseUrl(baseUrl.trim());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||
builder.model(runtimeModel.getModelName());
|
||||
}
|
||||
|
||||
// Extended thinking — request-level depth from ThinkingLevelHolder
|
||||
String thinkingLevel = ThinkingLevelHolder.get();
|
||||
boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||
|
||||
if (thinkingEnabled) {
|
||||
// Anthropic thinking mode constraints: temperature MUST be 1, top_p forbidden,
|
||||
// max_tokens must accommodate budget_tokens + a buffer for the actual answer.
|
||||
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
|
||||
case "low" -> 4096;
|
||||
case "medium" -> 8192;
|
||||
case "high" -> 16384;
|
||||
case "max" -> 32768;
|
||||
default -> 16384;
|
||||
};
|
||||
builder.thinking(AnthropicApi.ThinkingType.ENABLED, budgetTokens);
|
||||
builder.maxTokens(Math.max(budgetTokens + 4096,
|
||||
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
|
||||
builder.temperature(1.0);
|
||||
} else {
|
||||
// Non-thinking: Anthropic refuses to accept temperature AND top_p simultaneously.
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.temperature(runtimeModel.getTemperature());
|
||||
} else if (runtimeModel.getTopP() != null) {
|
||||
builder.topP(runtimeModel.getTopP());
|
||||
}
|
||||
// RFC-025: 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) {
|
||||
builder.maxTokens(configuredMax);
|
||||
} else {
|
||||
if (configuredMax != null) {
|
||||
log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096",
|
||||
configuredMax, runtimeModel.getModelName());
|
||||
}
|
||||
builder.maxTokens(4096);
|
||||
}
|
||||
}
|
||||
// RFC-014: prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
|
||||
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||
|
||||
return builder.internalToolExecutionEnabled(false).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply 10s connect / 180s read timeouts. The 180s read covers the case
|
||||
* 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.
|
||||
*/
|
||||
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
|
||||
rf.setReadTimeout(Duration.ofSeconds(180));
|
||||
return builder.requestFactory(rf);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +1,47 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
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;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thin strategy adapter for {@link ModelProtocol#DASHSCOPE_NATIVE}.
|
||||
* Strategy implementation for {@link ModelProtocol#DASHSCOPE_NATIVE}.
|
||||
*
|
||||
* <p>This is a deliberate <b>delegating</b> implementation: it calls back into
|
||||
* {@link AgentGraphBuilder}'s package-private helpers ({@code buildDashScopeApi},
|
||||
* {@code buildDashScopeOptions}) rather than owning the build logic itself.
|
||||
* The goal of this PR is to install the strategy seam (so
|
||||
* {@code ProviderChatModelFactory} can route requests without circular
|
||||
* dependencies) without taking on the risk of relocating ~600 lines of
|
||||
* provider-specific helpers in a single change.</p>
|
||||
*
|
||||
* <p>A follow-up PR (PR-0b in the plan) moves the helpers into this class so
|
||||
* {@code AgentGraphBuilder} can shed the protocol-specific code entirely.</p>
|
||||
*
|
||||
* <p>{@code @Lazy} on the {@link AgentGraphBuilder} dependency breaks the
|
||||
* factory ↔ builder ↔ AgentGraphBuilder bean-creation cycle:
|
||||
* AgentGraphBuilder constructs ProviderChatModelFactory, the factory needs
|
||||
* builders, and this builder needs AgentGraphBuilder back. The lazy proxy
|
||||
* defers AgentGraphBuilder resolution until the first {@link #build} call.</p>
|
||||
* <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>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final DashScopeChatModel dashScopeChatModel;
|
||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
public AgentDashScopeChatModelBuilder(@Lazy AgentGraphBuilder agentGraphBuilder,
|
||||
DashScopeChatModel dashScopeChatModel) {
|
||||
this.agentGraphBuilder = agentGraphBuilder;
|
||||
public AgentDashScopeChatModelBuilder(DashScopeChatModel dashScopeChatModel,
|
||||
DashScopeConnectionProperties dashScopeConnectionProperties,
|
||||
ModelProviderService modelProviderService) {
|
||||
this.dashScopeChatModel = dashScopeChatModel;
|
||||
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
|
||||
this.modelProviderService = modelProviderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -52,11 +51,158 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
DashScopeApi api = agentGraphBuilder.buildDashScopeApi(provider);
|
||||
DashScopeChatOptions options = agentGraphBuilder.buildDashScopeOptions(model, provider);
|
||||
DashScopeApi api = buildDashScopeApi(provider);
|
||||
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
|
||||
return dashScopeChatModel.mutate()
|
||||
.dashScopeApi(api)
|
||||
.defaultOptions(options)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* DashScope's built-in web search is on by default; only an explicit
|
||||
* {@code enableSearch=false} in provider kwargs disables it. Public so
|
||||
* {@code AgentGraphBuilder.build()} can surface the "built-in search
|
||||
* active" log once per agent.
|
||||
*/
|
||||
public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
Object kwargsSearch = kwargs.get("enableSearch");
|
||||
if (kwargsSearch != null) {
|
||||
return Boolean.TRUE.equals(kwargsSearch);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
|
||||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||
builder.withModel(runtimeModel.getModelName());
|
||||
}
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.withTemperature(runtimeModel.getTemperature());
|
||||
}
|
||||
if (runtimeModel.getMaxTokens() != null) {
|
||||
builder.withMaxToken(runtimeModel.getMaxTokens());
|
||||
}
|
||||
if (runtimeModel.getTopP() != null) {
|
||||
builder.withTopP(runtimeModel.getTopP());
|
||||
}
|
||||
if (isBuiltinSearchEnabled(runtimeModel, provider)) {
|
||||
builder.withEnableSearch(true);
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
}
|
||||
if (StringUtils.hasText(strategy)) {
|
||||
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||||
.searchStrategy(strategy)
|
||||
.enableSource(true)
|
||||
.enableCitation(true)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
|
||||
DashScopeApi.Builder builder = DashScopeApi.builder();
|
||||
|
||||
// API Key fallback chain: provider UI config → env / application.yml → default bean reflection
|
||||
String apiKey = provider != null ? provider.getApiKey() : null;
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = dashScopeConnectionProperties.getApiKey();
|
||||
}
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = readApiKeyFromDefaultChatModel();
|
||||
}
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.dashscope_key_missing",
|
||||
"DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key,或设置 DASHSCOPE_API_KEY 环境变量");
|
||||
}
|
||||
builder.apiKey(apiKey.trim());
|
||||
|
||||
// Base URL fallback chain — same priority as API Key
|
||||
String baseUrl = provider != null ? provider.getBaseUrl() : null;
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||||
}
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = readBaseUrlFromDefaultChatModel();
|
||||
}
|
||||
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
|
||||
if (StringUtils.hasText(normalizedBaseUrl)) {
|
||||
builder.baseUrl(normalizedBaseUrl);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the OpenAI compatible-mode path off any user-supplied URL
|
||||
* (common when migrating from compat-mode), trim trailing slash, and
|
||||
* return null when the result is the SDK default — letting Spring AI's
|
||||
* built-in default win avoids path-concat surprises.
|
||||
*/
|
||||
private String normalizeDashScopeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = baseUrl.trim();
|
||||
int compatibleIndex = normalized.indexOf("/compatible-mode/");
|
||||
if (compatibleIndex >= 0) {
|
||||
normalized = normalized.substring(0, compatibleIndex);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reflection helpers — read whatever the auto-configured default
|
||||
// DashScopeChatModel was built with, as a final fallback when no
|
||||
// explicit credentials reach us.
|
||||
// ============================================================
|
||||
|
||||
private String readApiKeyFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) return null;
|
||||
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
|
||||
apiKeyField.setAccessible(true);
|
||||
Object apiKey = apiKeyField.get(api);
|
||||
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
|
||||
return key.getValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readBaseUrlFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) return null;
|
||||
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
|
||||
baseUrlField.setAccessible(true);
|
||||
Object baseUrl = baseUrlField.get(api);
|
||||
return baseUrl instanceof String value ? value : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||||
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||||
apiField.setAccessible(true);
|
||||
Object api = apiField.get(dashScopeChatModel);
|
||||
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user