* 用于调用方(如 Wiki 消化管线)已经有自己的外层重试策略,
* 希望绕过 Spring AI 内层重试、独占重试控制权的场景:传入
* {@code RetryTemplate.builder().maxAttempts(1).build()} 即可把内层降级为"只跑一次"。
*
* DashScope 和 OpenAI-ChatGPT 分支不走 Spring AI 的 RetryTemplate 接口,
* 本参数对它们无效(它们各自有内部重试或直通)。
*/
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel, RetryTemplate retryOverride) {
// PR-0 (RFC-009 Phase 4 prelude): protocol switch extracted to
// ProviderChatModelFactory + per-protocol ChatModelBuilder strategies.
// Per-protocol builders (DashScope / OpenAI-compatible / Anthropic /
// ChatGPT-Responses) live in vip.mate.agent.chatmodel + vip.mate.llm.chatmodel.
// See RFC-009 Phase 4 plan for the rationale (circular-dep break for
// ProviderInitProbe + AgentGraphBuilder slimming).
return chatModelFactory.buildFor(runtimeModel, retryOverride);
}
/**
* RFC-009: build the full multi-provider failover chain for a primary
* model. Providers are read from {@code mate_model_provider} ordered by
* {@code fallback_priority ASC} (positive values only), each resolved to
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
* via {@link #buildRuntimeChatModel(ModelConfigEntity, RetryTemplate)}.
*
*
Providers whose API key / base URL is missing (build throws) are
* silently skipped with a warning — fallback should never break
* the primary call path. The returned list preserves chain order; the
* streaming helper tries entries in order until one succeeds.
*
*
The primary model is excluded from the chain when its provider +
* model name matches a chain entry. Previously only reference equality
* was checked, which meant a DashScope-primary deployment ended up with
* {@code null} fallback — exactly the case RFC-009 targets.
*
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
* the primary model; used to identity-filter the chain
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
*/
List buildFallbackChain(ModelConfigEntity primaryModelConfig) {
return buildFallbackChain(primaryModelConfig, null);
}
/**
* RFC-009 PR-3 overload: when {@code agentId} is non-null, the agent's
* {@code mate_agent_provider_preference} rows bias the chain order — listed
* providers come first in their declared {@code sort_order}, then the
* remaining providers fall in by global {@code fallback_priority} ascending,
* tie-broken by provider id alphabetically. {@code null} agentId keeps the
* pre-PR-3 ordering (pure global priority) — that's the path for legacy
* callers and tests.
*
*
Source = the available pool (RFC-009 follow-up). Earlier this
* method only considered providers with {@code fallback_priority > 0}, which
* meant any provider the user hadn't explicitly opted into the chain was
* silently excluded — even if it was healthy and in the pool. The pool is
* the source of truth for "what's usable right now"; {@code fallback_priority}
* is just an ordering hint within the pool.
*
*
Per-provider model selection falls back gracefully: the
* provider's {@code is_default=true} chat model wins, otherwise we pick
* the first enabled chat model on that provider. Forcing users to mark a
* default per provider was administrative friction with no real benefit.
*/
List buildFallbackChain(ModelConfigEntity primaryModelConfig,
Long agentId) {
List providers;
try {
// Pull every configured provider, not just the ones with
// fallback_priority > 0 — pool membership is what gates usability,
// not this admin-set hint.
providers = modelProviderService.listProviders().stream()
.filter(dto -> Boolean.TRUE.equals(dto.getConfigured()))
.map(dto -> {
try {
return modelProviderService.getProviderConfig(dto.getId());
} catch (Exception e) {
return null;
}
})
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
} catch (Exception e) {
log.warn("[LlmFailover] failed to load configured providers: {}; running without fallback",
e.getMessage());
return List.of();
}
if (providers.isEmpty()) {
return List.of();
}
// Order: explicit fallback_priority > 0 wins (asc), priority == 0 trails alphabetically.
providers.sort((a, b) -> {
int pa = a.getFallbackPriority() == null ? 0 : a.getFallbackPriority();
int pb = b.getFallbackPriority() == null ? 0 : b.getFallbackPriority();
if (pa > 0 && pb > 0) return Integer.compare(pa, pb);
if (pa > 0) return -1; // a has explicit priority, comes first
if (pb > 0) return 1; // b has explicit priority, comes first
return a.getProviderId().compareTo(b.getProviderId()); // both 0: alphabetical
});
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
// their declared order; everything else keeps the global priority order.
List preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderIds(agentId);
if (!preferred.isEmpty()) {
providers = reorderByPreferences(providers, preferred);
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
}
List chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// Don't put the primary provider's row into the fallback chain — same-instance
// skipping is also done in the runtime walker, but excluding here saves building
// a duplicate ChatModel at agent-build time.
if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) {
log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId);
continue;
}
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in
// NodeStreamingChatHelper re-checks pool membership per request, so a provider
// that re-enters the pool later still gets used (the graph is rebuilt on
// ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(p.getProviderId())) {
log.debug("[LlmFailover] skipping provider {} — not in available pool",
p.getProviderId());
continue;
}
ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId());
if (fallbackConfig == null) {
log.debug("[LlmFailover] skipping provider {} — no enabled chat model",
p.getProviderId());
continue;
}
if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) {
// Same model name picked for a different provider — exact same call, skip.
continue;
}
try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m));
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
p.getFallbackPriority());
} catch (Exception e) {
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
p.getProviderId(), e.getMessage());
}
}
return chain;
}
/**
* Pick a chat model to use as a fallback for the given provider:
*
*
Provider's explicit default ({@code is_default=true}) — most user-aligned.
*
First enabled chat model on the provider — pragmatic fallback so the user
* isn't required to mark a default per provider just to participate in failover.
*
* Returns {@code null} when the provider has no usable chat model.
*/
private ModelConfigEntity pickFallbackModel(String providerId) {
try {
ModelConfigEntity defaultModel = modelConfigService.getDefaultModelByProvider(providerId);
if (defaultModel != null) return defaultModel;
} catch (Exception ignored) {
// No default — fall through to first-enabled lookup.
}
try {
return modelConfigService.listModelsByProvider(providerId).stream()
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
.filter(m -> m.getModelType() == null || "chat".equals(m.getModelType()))
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("[LlmFailover] cannot list models for provider {}: {}", providerId, e.getMessage());
return null;
}
}
/**
* Reorder a provider list by an agent's preference list. Listed provider
* ids come first in their preference order; any provider not in the
* preference list keeps its original position relative to other unlisted
* providers (stable partition). Preference entries that don't match any
* actual provider are silently dropped.
*/
/** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
static List reorderByPreferences(List providers,
List preferredOrder) {
Map byId = new java.util.LinkedHashMap<>();
for (ModelProviderEntity p : providers) {
byId.put(p.getProviderId(), p);
}
List reordered = new ArrayList<>(providers.size());
Set placed = new java.util.HashSet<>();
for (String prefId : preferredOrder) {
ModelProviderEntity p = byId.get(prefId);
if (p != null && placed.add(prefId)) {
reordered.add(p);
}
}
for (ModelProviderEntity p : providers) {
if (placed.add(p.getProviderId())) {
reordered.add(p);
}
}
return reordered;
}
/**
* Finds the first enabled chat model whose provider is fully configured.
* Used as a fallback when the default model's provider is not available.
*/
private ModelConfigEntity findFirstAvailableChatModel() {
return modelConfigService.listByType("chat").stream()
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
.filter(m -> {
try {
return modelProviderService.isProviderConfigured(m.getProvider());
} catch (Exception e) {
return false;
}
})
.findFirst()
.orElse(null);
}
// PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers).
// PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder.
// ==================== Prompt 构建 ====================
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
// 通过 MemoryManager 从所有 MemoryProvider 组装系统提示词(快照冻结)
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
String basePrompt = (memoryPrompt != null && !memoryPrompt.isBlank())
? memoryPrompt
: (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : "");
// 使用 skill runtime 构建技能增强(per-agent 绑定过滤)
Set boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds);
// 工具调用指导
String toolGuidance = """
## Runtime Context
- Current Agent ID: %s
## Workspace Memory Guidelines
Your durable memory is stored in database-backed workspace markdown files for this agent:
- `PROFILE.md`: stable user profile, preferences, collaboration style
- `MEMORY.md`: distilled long-term memory, durable facts, lessons, recurring patterns
- `memory/YYYY-MM-DD.md`: daily notes, raw events, temporary observations, open loops
Use workspace memory tools instead of local filesystem tools for those files:
- `list_workspace_memory_files(agentId=..., filenamePrefix=...)`
- `read_workspace_memory_file(agentId=..., filename=...)`
- `write_workspace_memory_file(agentId=..., filename=..., content=...)`
- `edit_workspace_memory_file(agentId=..., filename=..., oldText=..., newText=...)`
Memory writing policy:
- Stable user preference, identity, collaboration habit -> `PROFILE.md`
- Stable project fact, workflow, tool setup, lesson learned, recurring decision -> `MEMORY.md`
- One-off event, meeting note, temporary context, today's decision trace -> `memory/YYYY-MM-DD.md`
- Read before write unless you are creating a brand new daily note
- Do not store secrets or highly sensitive data unless the user explicitly asks
- Updating workspace memory files is internal state maintenance for this agent and can be done proactively when useful
Memory emergence policy:
- If the same preference, constraint, workflow, or lesson appears repeatedly, consolidate it from daily notes into `MEMORY.md`
- Prefer updating an existing section over appending duplicate bullets
- Treat `MEMORY.md` as a compact mental model, not a raw transcript dump
- When answering tasks involving prior decisions, preferences, habits, or ongoing work, proactively consult relevant workspace memory first
## Structured Memory Tools
For discrete, typed facts use structured memory tools (separate from workspace files):
- `remember_structured(agentId, type, key, content)` — store a typed entry
- `recall_structured(agentId, type, keyword)` — search entries by type and/or keyword
- `forget_structured(agentId, type, key)` — remove an entry
Types:
- `user`: preferences, expertise, communication style, role
- `feedback`: behavioral corrections or confirmed approaches (include WHY)
- `project`: decisions, deadlines, constraints not derivable from code/git
- `reference`: pointers to external systems (Linear boards, Grafana dashboards, Slack channels)
Use workspace memory tools (MEMORY.md, daily notes) for long-form narrative notes.
Use structured memory tools for key-value facts the system can query efficiently.
## Session Search
- `session_search(agentId, currentConversationId, mode, query, limit)` — search conversation history
- mode="recent": list recent conversations (titles, times, message counts)
- mode="search": keyword full-text search across past messages
- Use this to recall previous discussions, look up past decisions, or find context from earlier conversations
## Tool Usage Guidelines
When you have available tools, use them to access local system information, files, or execute commands.
Do not assume you cannot access local resources - try calling the appropriate tool first.
If a tool requires approval due to security policies, the system will prompt the user for confirmation.
Only state you cannot access something if no relevant tool is available.
## Multi-Part Question Guidelines
When the user asks multiple questions or requests multiple tasks in a single message:
1. Structure your final answer with numbered sections, one per sub-task
2. Each section must contain the complete, detailed result for that sub-task
3. Never compress earlier sub-tasks into summary sentences while expanding the last one
4. If observations were summarized during processing, reconstruct each section from the summary
5. Treat each sub-task's result as equally important regardless of processing order
## File Reading Guidelines
**Text Files** (use read_file):
For .txt, .md, .json, .yaml, .csv, .log, .py, .java, .js, .html, .xml, .sql, .conf, .ini, .toml files.
**Office/PDF Documents** (DO NOT use read_file):
For .pdf, .docx, .doc, .xlsx, .xls, .pptx, .ppt files, NEVER use read_file.
Instead use:
- detect_file_type(filePath="...") - to check file type first
- extract_document_text(filePath="...") - general document extraction
- extract_pdf_text(filePath="...") - for PDF files
- extract_docx_text(filePath="...") - for Word documents
Example workflow for document:
1. detect_file_type(filePath="/path/to/document.pdf")
2. Based on result, use extract_pdf_text() or extract_document_text()
3. Process the extracted text content
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
""".formatted(entity.getId());
String searchGuidance = "";
if (builtinSearchEnabled) {
searchGuidance = """
## Web Search Capability
You have **dual search capability**:
1. **Built-in search** (preferred): Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly — your response already includes real-time search data.
2. **search tool** (supplementary): Available as a fallback. Supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10).
### Priority Rules
- **Default**: Answer directly using built-in search. Do NOT say you cannot search — your replies already include live results.
- **Use search tool** ONLY when: you need precise time filtering (e.g., user asks for "yesterday's news" → call search with freshness=day), specific language results, or your built-in results feel insufficient.
- **NEVER** call both browser_use and search tool for the same query.
- When searching for news, use the standard format: `📰 [Category] Title — Source | Time + Summary`, up to 5 results per category.
""";
}
// Wiki 知识库上下文注入
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
return basePrompt + skillEnhancement + toolGuidance + searchGuidance + wikiContext;
}
// ==================== 模型选项构建 ====================
// PR-0b: buildDashScopeOptions moved to AgentDashScopeChatModelBuilder
/** 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 kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String modelName = runtimeModel.getModelName();
ModelFamily family = ModelFamily.detect(modelName);
if (StringUtils.hasText(modelName)) {
builder.model(modelName);
}
// temperature:部分模型族强制 1.0
Double temperature = resolveOpenAiTemperature(modelName, runtimeModel.getTemperature(), kwargs, family);
if (temperature != null) {
builder.temperature(temperature);
}
// max_tokens / max_completion_tokens:按模型族路由
if (family.suppressMaxTokens()) {
// OPENAI_REASONING 族:禁止 max_tokens,改用 max_completion_tokens
// fallback 优先级:kwargs.maxCompletionTokens > kwargs.maxTokens > config.maxTokens
Integer kwargsMaxTokens = resolveIntegerOption("maxTokens", runtimeModel.getMaxTokens(), kwargs);
Integer maxCompletionTokens = 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 {
// 其他模型族:正常使用 max_tokens
Integer maxTokens = resolveIntegerOption("maxTokens", runtimeModel.getMaxTokens(), kwargs);
if (maxTokens != null) {
builder.maxTokens(maxTokens);
}
// 仍允许通过 generateKwargs 手动指定 maxCompletionTokens
Integer maxCompletionTokens = resolveIntegerOption("maxCompletionTokens", null, kwargs);
if (maxCompletionTokens != null) {
builder.maxCompletionTokens(maxCompletionTokens);
}
}
// top_p:部分模型族禁止发送
Double topP = resolveOpenAiTopP(modelName, runtimeModel.getTopP(), kwargs, family);
if (topP != null) {
builder.topP(topP);
}
// reasoning_effort:仅支持的模型族才注入
String reasoningEffort = resolveReasoningEffort(modelName, kwargs, family);
if (StringUtils.hasText(reasoningEffort)) {
builder.reasoningEffort(reasoningEffort);
}
// 内置搜索:模型级字段优先,provider generateKwargs 作为 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);
// 注意:不设置 parallelToolCalls — 设为 false 会导致无 tools 时 OpenAI 返回 400:
// "parallel_tool_calls is only allowed when 'tools' are specified"
// 保持 null 让 Spring AI 不序列化该字段,由各 Node 在有 tools 时自行控制。
options.setStreamUsage(true);
return options;
}
// ==================== OpenAI API 构建 ====================
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
String apiKey = provider.getApiKey();
if (!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 kwargs = modelProviderService.readProviderGenerateKwargs(provider);
MultiValueMap headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
// Spring AI OpenAiApi 构造函数会先 set User-Agent 为 "spring-ai",再 addAll 我们的 headers,
// 导致自定义 User-Agent 被追加而非覆盖。因此对需要伪装客户端身份的 provider(如 kimi-code),
// 通过 RestClient/WebClient 拦截器在请求发出前强制覆盖 headers。
Map 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"));
return new OpenAiApi(
baseUrl,
new SimpleApiKey(apiKey.trim()),
headers,
completionsPath,
"/v1/embeddings",
restClientBuilder,
webClientBuilder,
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) {
@Override
public org.springframework.http.ResponseEntity chatCompletionEntity(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap additionalHttpHeader) {
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
try {
return super.chatCompletionEntity(chatRequest, additionalHttpHeader);
} catch (WebClientResponseException e) {
logOpenAiError(provider, e);
throw e;
}
}
@Override
public Flux chatCompletionStream(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap additionalHttpHeader) {
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
return super.chatCompletionStream(chatRequest, additionalHttpHeader)
.doOnError(error -> {
if (error instanceof WebClientResponseException e) {
logOpenAiError(provider, e);
}
});
}
};
}
// ==================== DashScope API 构建 ====================
// PR-0b: buildDashScopeApi moved to AgentDashScopeChatModelBuilder
// ==================== Anthropic API 构建 ====================
// PR-0b: buildAnthropicApi + buildAnthropicOptions moved to AgentAnthropicChatModelBuilder
// ==================== 参数解析辅助方法 ====================
private Double resolveOpenAiTemperature(String modelName, Double configuredTemperature,
Map kwargs, ModelFamily family) {
Double overriddenTemperature = 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 kwargs, ModelFamily family) {
if (family.suppressTopP()) {
return null;
}
return resolveDoubleOption("topP", configuredTopP, kwargs);
}
private boolean requiresFixedTemperatureOne(String modelName) {
return ModelFamily.detect(modelName).fixedTemperatureOne();
}
private String resolveReasoningEffort(String modelName, Map kwargs, ModelFamily family) {
// PR-1.1 (RFC-049 L1-A): Only families that actually accept reasoning_effort may receive
// it. Previously only the default-inject branch checked capability; the generateKwargs
// override branch did not, so a provider-level `reasoningEffort: "high"` would leak to
// deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering the incident documented
// in RFC-049 (DeepSeek "reasoning_content missing" 400).
if (!family.supportsReasoningEffort()) {
Object overridden = findOptionValue(kwargs, "reasoningEffort");
if (overridden != null) {
log.warn("Dropping reasoningEffort='{}' from generateKwargs — model '{}' (family={}) "
+ "does not accept reasoning_effort. For DeepSeek thinking use "
+ "extra_body.thinking; for Kimi thinking the model activates it natively.",
overridden, modelName, family);
}
return null;
}
// generateKwargs 显式覆盖始终优先(仅在白名单族内)
Object value = findOptionValue(kwargs, "reasoningEffort");
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
// 仅支持 reasoning_effort 的模型族才自动注入默认值
if (family.isThinking()) {
return "medium";
}
return null;
}
private boolean isThinkingModel(String modelName) {
return ModelFamily.detect(modelName).isThinking();
}
/**
* 从 ModelConfigEntity 中解析 reasoningEffort,用于传递给 StepExecutionNode / ReasoningNode。
* 复用已有的 resolveReasoningEffort + isThinkingModel 逻辑。
*/
private String resolveReasoningEffortForModel(ModelConfigEntity runtimeModel) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
Map kwargs = modelProviderService.readProviderGenerateKwargs(provider);
ModelFamily family = ModelFamily.detect(runtimeModel.getModelName());
return resolveReasoningEffort(runtimeModel.getModelName(), kwargs, family);
}
private Double resolveDoubleOption(String key, Double fallback, Map 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;
}
private Integer resolveIntegerOption(String key, Integer fallback, Map 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;
}
@SuppressWarnings("unchecked")
private Object findOptionValue(Map 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 Object findKwarg(Map 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) optionsMap).get(key);
}
return null;
}
// ==================== URL 规范化 ====================
// PR-0b: normalizeDashScopeBaseUrl moved to AgentDashScopeChatModelBuilder
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;
}
// ==================== Kimi 内置搜索 ====================
private static boolean isKimiProvider(ModelProviderEntity provider) {
if (provider == null) return false;
String id = provider.getProviderId();
return "kimi-cn".equals(id) || "kimi-intl".equals(id);
}
/**
* 为 Kimi 请求注入 $web_search builtin tool。
* Kimi 的内置搜索通过 tools 数组中声明 {"type":"builtin_function","function":{"name":"$web_search"}} 实现。
* 由于 Spring AI 的 FunctionTool.Type 只有 FUNCTION,无法直接构造 builtin_function 类型,
* 因此通过 extraBody 注入原始 JSON 结构覆盖 tools 字段(包含原有 tools + $web_search)。
*/
private static OpenAiApi.ChatCompletionRequest injectKimiWebSearch(OpenAiApi.ChatCompletionRequest request) {
// 构造 $web_search entry 作为 Map
Map webSearchTool = Map.of(
"type", "builtin_function",
"function", Map.of("name", "$web_search")
);
// 将原有 tools 转为 List