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.beans.factory.ObjectProvider; 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; import vip.mate.llm.service.ModelProviderService; import java.lang.reflect.Field; import java.util.Map; /** * Strategy implementation for {@link ModelProtocol#DASHSCOPE_NATIVE}. * *

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.

* *

DashScopeChatModel is injected via ObjectProvider so that the builder * degrades gracefully when DashScope auto-configuration is disabled or the * dependency is absent, rather than failing the entire application context.

*/ @Slf4j @Component public class AgentDashScopeChatModelBuilder implements ChatModelBuilder { private final ObjectProvider dashScopeChatModelProvider; private final DashScopeConnectionProperties dashScopeConnectionProperties; private final ModelProviderService modelProviderService; public AgentDashScopeChatModelBuilder(ObjectProvider dashScopeChatModelProvider, DashScopeConnectionProperties dashScopeConnectionProperties, ModelProviderService modelProviderService) { this.dashScopeChatModelProvider = dashScopeChatModelProvider; this.dashScopeConnectionProperties = dashScopeConnectionProperties; this.modelProviderService = modelProviderService; } @Override public ModelProtocol supportedProtocol() { return ModelProtocol.DASHSCOPE_NATIVE; } @Override public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) { DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable(); if (defaultModel == null) { throw new MateClawException("err.agent.dashscope_unavailable", "DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型"); } DashScopeApi api = buildDashScopeApi(provider); DashScopeChatOptions options = buildDashScopeOptions(model, provider); return defaultModel.mutate() .dashScopeApi(api) .defaultOptions(options) .build(); } /** * Bailian's built-in web search is only accepted by a subset of models; * sending {@code enable_search} to a model that doesn't support it returns * 400 InvalidParameter and the failover layer then evicts the entire * provider as MODEL_NOT_FOUND. Per the public docs, only Qwen-Plus, * Qwen-Max, Qwen-Turbo and the Qwen3-Max series accept the parameter. * *

Resolution order:

*
    *
  1. Explicit {@code enableSearch} in the model row (per-model toggle)
  2. *
  3. Explicit {@code enableSearch} in provider kwargs (admin-level toggle)
  4. *
  5. Default: enabled only when the model name matches a known-supporting * prefix; disabled for everything else (coder / thinking / DeepSeek / * Long / Vision)
  6. *
* *

Public so {@code AgentGraphBuilder.build()} can surface the * "built-in search active" log once per agent.

*/ public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) { if (runtimeModel != null && runtimeModel.getEnableSearch() != null) { return Boolean.TRUE.equals(runtimeModel.getEnableSearch()); } Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); Object kwargsSearch = kwargs.get("enableSearch"); if (kwargsSearch != null) { return Boolean.TRUE.equals(kwargsSearch); } return modelSupportsBuiltinSearch(runtimeModel); } /** * Model id prefixes that the Bailian text-generation endpoint documents as * accepting {@code enable_search}. Anything else (qwen-coder-*, qwen-long, * qwen3-*-thinking-*, deepseek-*, qwen*-vl-*) returns 400 InvalidParameter * when the parameter is sent. */ private static final java.util.List BUILTIN_SEARCH_SUPPORTED_PREFIXES = java.util.List.of( "qwen-plus", "qwen-max", "qwen-turbo", "qwen3-max", "qwen3.5-flash", "qwen3.6-flash" ); private static boolean modelSupportsBuiltinSearch(ModelConfigEntity model) { if (model == null) return false; String name = model.getModelName(); if (!StringUtils.hasText(name)) return false; String lower = name.trim().toLowerCase(); for (String prefix : BUILTIN_SEARCH_SUPPORTED_PREFIXES) { if (lower.startsWith(prefix)) return true; } return false; } DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) { DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder(); Map 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 { DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable(); if (defaultModel == null) { return null; } Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi"); apiField.setAccessible(true); Object api = apiField.get(defaultModel); return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null; } }