From 92116a54b423a6d2de8ea40561f2e7388d738803 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Sun, 19 Jul 2026 18:43:02 +0800 Subject: [PATCH] fix(ai): derive safe model output token limits Hide output-token configuration from the model form while preserving existing explicit overrides. Derive a shared output limit for model requests and context compression when the value is absent or invalid. Reject context windows below 32K and fall back with a warning when an explicit output limit exceeds 40% of the context window. Refs #1558 --- .../src/agentic/execution/execution_engine.rs | 11 +-- .../core/src/service/config/providers.rs | 32 +++++- .../assembly/core/src/service/config/types.rs | 27 +++++- .../assembly/core/src/util/types/config.rs | 97 ++++++++++++++++++- .../config/components/AIModelConfig.tsx | 69 ++++++------- .../src/locales/en-US/settings/ai-model.json | 1 + .../src/locales/zh-CN/settings/ai-model.json | 1 + .../src/locales/zh-TW/settings/ai-model.json | 1 + 8 files changed, 194 insertions(+), 45 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index b093786503..7c4930aa35 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -39,7 +39,7 @@ use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; use crate::service::config::types::{ - model_runtime_binding_fingerprint, ModelCapability, ModelCategory, + automatic_max_output_tokens, model_runtime_binding_fingerprint, ModelCapability, ModelCategory, }; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; @@ -342,7 +342,6 @@ pub struct ExecutionEngine { } impl ExecutionEngine { - const AUTO_COMPRESSION_DEFAULT_OUTPUT_RESERVE_TOKENS: usize = 16_000; const AUTO_COMPRESSION_SAFETY_RESERVE_TOKENS: usize = 10_000; const FINALIZE_AFTER_REPEATED_TOOL_FAILURES_REMINDER: &'static str = "This turn must end now because repeated tool failures have prevented further progress. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize what was completed, what failed, the evidence available from the tool results, and the single best next step for the user."; const FINALIZE_AFTER_MAX_ROUNDS_REMINDER: &'static str = "This turn must end now because it has reached the round limit. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize the most useful completed work and evidence collected so far, and clearly distinguish resolved items from anything still unresolved."; @@ -516,7 +515,7 @@ impl ExecutionEngine { ) -> CompressionTriggerBudget { let output_reserve_tokens = configured_max_tokens .map(|value| value as usize) - .unwrap_or(Self::AUTO_COMPRESSION_DEFAULT_OUTPUT_RESERVE_TOKENS); + .unwrap_or_else(|| automatic_max_output_tokens(context_window as u32) as usize); let safety_reserve_tokens = Self::AUTO_COMPRESSION_SAFETY_RESERVE_TOKENS; let input_limit = context_window.saturating_sub(output_reserve_tokens + safety_reserve_tokens); @@ -4074,12 +4073,12 @@ mod tests { } #[test] - fn compression_trigger_budget_uses_16k_output_reserve_when_max_tokens_is_unset() { + fn compression_trigger_budget_uses_the_automatic_output_tier_when_max_tokens_is_unset() { let budget = ExecutionEngine::compression_trigger_budget(128_000, None); - assert_eq!(budget.output_reserve_tokens, 16_000); + assert_eq!(budget.output_reserve_tokens, 32_000); assert_eq!(budget.safety_reserve_tokens, 10_000); - assert_eq!(budget.input_limit, 102_000); + assert_eq!(budget.input_limit, 86_000); } #[test] diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 338eefbe92..61cbf7bbb9 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -72,10 +72,10 @@ impl ConfigProvider for AIConfigProvider { warnings.push(format!("Model '{}' has empty API key", model.name)); } if let Some(context_window) = model.context_window { - if context_window == 0 { + if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { return Err(BitFunError::validation(format!( - "Model '{}' context_window must be greater than 0", - model.name + "Model '{}' context_window must be at least {}", + model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS ))); } } @@ -160,6 +160,32 @@ impl ConfigProvider for AIConfigProvider { } } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_a_model_context_window_smaller_than_32k() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Test model".to_string(), + provider: "openai".to_string(), + context_window: Some(MIN_MODEL_CONTEXT_WINDOW_TOKENS - 1), + ..AIModelConfig::default() + }); + let value = serde_json::to_value(config).expect("AI config should serialize"); + + let error = AIConfigProvider + .validate_config(&value) + .await + .expect_err("small context windows must be rejected"); + + assert!(error + .to_string() + .contains("context_window must be at least 32000")); + } +} + /// Theme system configuration provider (new, supports theme management). pub struct ThemesConfigProvider; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 2759dd70b1..07feed2111 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -1222,6 +1222,30 @@ pub enum AgentSubagentOverrideState { pub type ParentSubagentOverrideConfig = HashMap; pub type AgentSubagentOverrideConfig = HashMap; +pub const DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 128_128; +pub const MIN_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 32_000; +pub const MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT: u32 = 40; +const AUTOMATIC_MAX_OUTPUT_TOKEN_TIERS: [u32; 5] = [8_000, 16_000, 24_000, 32_000, 64_000]; + +/// Chooses the largest supported output tier that does not exceed one quarter +/// of the model context window. +pub fn automatic_max_output_tokens(context_window: u32) -> u32 { + let quarter_context = context_window / 4; + AUTOMATIC_MAX_OUTPUT_TOKEN_TIERS + .iter() + .rev() + .copied() + .find(|tier| *tier <= quarter_context) + .unwrap_or(quarter_context) +} + +/// A configured output cap may use up to 40% of the model context window. +pub fn is_valid_configured_max_output_tokens(context_window: u32, max_tokens: u32) -> bool { + max_tokens > 0 + && u64::from(max_tokens) * 100 + <= u64::from(context_window) * u64::from(MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, from = "AIModelConfigCompat")] pub struct AIModelConfig { @@ -1239,7 +1263,8 @@ pub struct AIModelConfig { pub api_key: String, /// Context window size (total token limit for input + output). pub context_window: Option, - /// Max output tokens (request parameter limiting model output length). + /// Optional advanced override for the request output limit. When absent, + /// BitFun derives a tiered limit from the context window at runtime. pub max_tokens: Option, pub temperature: Option, pub top_p: Option, diff --git a/src/crates/assembly/core/src/util/types/config.rs b/src/crates/assembly/core/src/util/types/config.rs index d5c261588b..42a75e6942 100644 --- a/src/crates/assembly/core/src/util/types/config.rs +++ b/src/crates/assembly/core/src/util/types/config.rs @@ -1,4 +1,9 @@ use crate::service::config::types::AIModelConfig; +use crate::service::config::types::{ + automatic_max_output_tokens, is_valid_configured_max_output_tokens, + DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS, MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT, + MIN_MODEL_CONTEXT_WINDOW_TOKENS, +}; pub use bitfun_core_types::AIConfig; use log::warn; @@ -94,6 +99,41 @@ impl TryFrom for AIConfig { resolve_request_url(&other.base_url, &other.provider, &other.model_name) }); + let context_window = other + .context_window + .unwrap_or(DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS); + if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { + return Err(format!( + "Model '{}' context_window must be at least {}", + other.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS + )); + } + + let max_tokens = match other.max_tokens { + Some(configured_max_tokens) + if is_valid_configured_max_output_tokens(context_window, configured_max_tokens) => + { + configured_max_tokens + } + Some(configured_max_tokens) => { + let automatic_max_tokens = automatic_max_output_tokens(context_window); + let maximum_allowed_tokens = u64::from(context_window) + * u64::from(MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT) + / 100; + warn!( + "Invalid model max_tokens; falling back to automatic output limit: model_id={}, model_name={}, context_window={}, configured_max_tokens={}, maximum_allowed_tokens={}, automatic_max_tokens={}", + other.id, + other.name, + context_window, + configured_max_tokens, + maximum_allowed_tokens, + automatic_max_tokens + ); + automatic_max_tokens + } + None => automatic_max_output_tokens(context_window), + }; + Ok(AIConfig { name: other.name.clone(), base_url: other.base_url.clone(), @@ -101,8 +141,8 @@ impl TryFrom for AIConfig { api_key: other.api_key.clone(), model: other.model_name.clone(), format: other.provider.clone(), - context_window: other.context_window.unwrap_or(128128), - max_tokens: other.max_tokens, + context_window, + max_tokens: Some(max_tokens), temperature: other.temperature, top_p: other.top_p, reasoning_mode, @@ -237,4 +277,57 @@ mod tests { let config = AIConfig::try_from(model).expect("conversion should succeed"); assert_eq!(config.reasoning_mode, ReasoningMode::Enabled); } + + #[test] + fn derives_the_largest_output_tier_within_one_quarter_of_context() { + for (context_window, expected_max_tokens) in [ + (32_000, 8_000), + (48_000, 8_000), + (64_000, 16_000), + (128_000, 32_000), + (128_128, 32_000), + (256_000, 64_000), + (1_000_000, 64_000), + ] { + let mut model = base_model_config(); + model.context_window = Some(context_window); + model.max_tokens = None; + + let config = AIConfig::try_from(model).expect("conversion should succeed"); + + assert_eq!(config.max_tokens, Some(expected_max_tokens)); + } + } + + #[test] + fn preserves_a_configured_output_limit_within_forty_percent_of_context() { + let mut model = base_model_config(); + model.context_window = Some(1_000_000); + model.max_tokens = Some(384_000); + + let config = AIConfig::try_from(model).expect("conversion should succeed"); + + assert_eq!(config.max_tokens, Some(384_000)); + } + + #[test] + fn falls_back_to_the_automatic_output_limit_when_configured_limit_is_too_large() { + let mut model = base_model_config(); + model.context_window = Some(128_000); + model.max_tokens = Some(64_000); + + let config = AIConfig::try_from(model).expect("conversion should succeed"); + + assert_eq!(config.max_tokens, Some(32_000)); + } + + #[test] + fn rejects_a_context_window_smaller_than_the_supported_minimum() { + let mut model = base_model_config(); + model.context_window = Some(16_000); + + let error = AIConfig::try_from(model).expect_err("conversion should reject small context"); + + assert!(error.contains("at least 32000")); + } } diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 2e7b464604..cdbfb492b1 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -37,7 +37,7 @@ interface SelectedModelDraft { modelName: string; category: ModelCategory; contextWindow: number; - maxTokens: number; + maxTokens?: number; reasoningMode: ReasoningMode; reasoningEffort?: string; thinkingBudgetTokens?: number; @@ -67,7 +67,7 @@ function createModelDraft( modelName: trimmedModelName, category: overrides?.category ?? baseConfig?.category ?? 'general_chat', contextWindow: overrides?.contextWindow ?? baseConfig?.context_window ?? 200000, - maxTokens: overrides?.maxTokens ?? baseConfig?.max_tokens ?? 32000, + maxTokens: overrides?.maxTokens ?? baseConfig?.max_tokens, reasoningMode: overrides?.reasoningMode ?? getEffectiveReasoningMode(baseConfig), reasoningEffort: overrides?.reasoningEffort ?? baseConfig?.reasoning_effort, thinkingBudgetTokens: overrides?.thinkingBudgetTokens ?? baseConfig?.thinking_budget_tokens, @@ -162,6 +162,24 @@ function formatTokenCountShort(n: number): string { return String(n); } +function automaticMaxOutputTokens(contextWindow: number): number { + const quarterContext = Math.floor(contextWindow / 4); + return [64000, 32000, 24000, 16000, 8000].find(tier => tier <= quarterContext) ?? quarterContext; +} + +function effectiveMaxOutputTokens(draft: SelectedModelDraft): number { + const configuredMaxTokens = draft.maxTokens; + if ( + configuredMaxTokens != null + && configuredMaxTokens > 0 + && configuredMaxTokens * 100 <= draft.contextWindow * 40 + ) { + return configuredMaxTokens; + } + + return automaticMaxOutputTokens(draft.contextWindow); +} + function parseOptionalPositiveIntegerInput(value: string): number | null | undefined { const trimmed = value.trim(); if (trimmed === '') { @@ -609,7 +627,7 @@ const AIModelConfig: React.FC = () => { configs.map(config => createModelDraft(config.model_name, config, { configId: config.id, contextWindow: config.context_window || 200000, - maxTokens: config.max_tokens || 32000, + maxTokens: config.max_tokens, reasoningMode: getEffectiveReasoningMode(config), reasoningEffort: config.reasoning_effort, thinkingBudgetTokens: config.thinking_budget_tokens, @@ -660,7 +678,11 @@ const AIModelConfig: React.FC = () => { }, reasoningProviderConfig); } - return normalizeDraftReasoningForProvider(createModelDraft(modelName, baseConfig, { + const draftBaseConfig = baseConfig + ? { ...baseConfig, max_tokens: undefined } + : undefined; + + return normalizeDraftReasoningForProvider(createModelDraft(modelName, draftBaseConfig, { configId: pinnedRowId, }), reasoningProviderConfig); }) @@ -782,7 +804,7 @@ const AIModelConfig: React.FC = () => { request_url: config.request_url || resolveRequestUrl(resolvedBaseUrl, resolvedProvider, resolvedModelName), model_name: resolvedModelName, context_window: config.context_window || 200000, - max_tokens: config.max_tokens || 32000, + max_tokens: config.max_tokens, temperature: config.temperature, top_p: config.top_p, enabled: config.enabled ?? true, @@ -905,7 +927,6 @@ const AIModelConfig: React.FC = () => { model_name: '', enabled: true, context_window: 200000, - max_tokens: 32000, category: 'general_chat', capabilities: ['text_chat', 'function_calling'], recommended_for: [], @@ -956,7 +977,6 @@ const AIModelConfig: React.FC = () => { provider: template.format, enabled: true, context_window: 200000, - max_tokens: 32000, category: 'general_chat', capabilities: ['text_chat', 'function_calling'], recommended_for: [], @@ -966,7 +986,6 @@ const AIModelConfig: React.FC = () => { setSelectedModelDrafts( defaultModel ? [createModelDraft(defaultModel, { context_window: 200000, - max_tokens: 32000, reasoning_mode: DEFAULT_REASONING_MODE, })] : [] ); @@ -992,8 +1011,6 @@ const AIModelConfig: React.FC = () => { provider: 'openai', enabled: true, context_window: 200000, - max_tokens: 32000, - category: 'general_chat', capabilities: ['text_chat'], recommended_for: [], @@ -1032,7 +1049,7 @@ const AIModelConfig: React.FC = () => { provider: config.provider, enabled: true, context_window: config.context_window || 200000, - max_tokens: config.max_tokens || 32000, + max_tokens: config.max_tokens, category: config.category || 'general_chat', capabilities: config.capabilities || getCapabilitiesByCategory(config.category || 'general_chat'), recommended_for: config.recommended_for || [], @@ -1064,7 +1081,7 @@ const AIModelConfig: React.FC = () => { setSelectedModelDrafts([ createModelDraft(config.model_name, config, { contextWindow: config.context_window || 200000, - maxTokens: config.max_tokens || 32000, + maxTokens: config.max_tokens, reasoningMode: getEffectiveReasoningMode(config), reasoningEffort: config.reasoning_effort, thinkingBudgetTokens: config.thinking_budget_tokens, @@ -1106,6 +1123,10 @@ const AIModelConfig: React.FC = () => { return; } const draftsToSave = dedupeSelectedModelDraftsByModelName(selectedModelDrafts); + if (draftsToSave.some(draft => draft.contextWindow < 32000)) { + notification.warning(t('messages.contextWindowTooSmall')); + return; + } const existingProviderInstanceId = getProviderInstanceId(editingConfig); const isProviderGroupEdit = !editingConfig.id && editingProviderModelIds.size > 0; const providerInstanceId = existingProviderInstanceId || generateProviderInstanceId(); @@ -1716,7 +1737,7 @@ const AIModelConfig: React.FC = () => { && draft.reasoningMode === 'enabled' && supportsAnthropicThinkingBudget(draft.modelName); const displayedThinkingBudget = draft.thinkingBudgetTokens - ?? Math.min(Math.floor(draft.maxTokens * 0.75), 10000); + ?? Math.min(Math.floor(effectiveMaxOutputTokens(draft) * 0.75), 10000); return (
{ {' · '} {formatTokenCountShort(draft.contextWindow)} ctx {' · '} - {formatTokenCountShort(draft.maxTokens)} out - {' · '} {formatReasoningSummary(draft)}
@@ -1818,25 +1837,13 @@ const AIModelConfig: React.FC = () => { updateModelDraft(draft.modelName, { contextWindow: value })} - min={1000} + min={32000} max={2000000} step={1000} size="small" disableWheel /> -
- {t('form.maxTokens')} - updateModelDraft(draft.modelName, { maxTokens: value })} - min={1000} - max={1000000} - step={1000} - size="small" - disableWheel - /> -
{showReasoningModeControl && (
{t('thinking.mode')} @@ -1872,7 +1879,7 @@ const AIModelConfig: React.FC = () => { value={displayedThinkingBudget} onChange={(value) => updateModelDraft(draft.modelName, { thinkingBudgetTokens: value || undefined })} min={1024} - max={50000} + max={Math.min(effectiveMaxOutputTokens(draft), 50000)} step={1024} size="small" disableWheel @@ -2444,10 +2451,6 @@ const AIModelConfig: React.FC = () => { {t('details.contextWindow')} {config.context_window != null ? i18nService.formatNumber(config.context_window) : '128,000'}
-
- {t('details.maxOutput')} - {config.max_tokens != null ? i18nService.formatNumber(config.max_tokens) : '-'} -
{t('details.apiUrl')} {config.base_url} diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index a471622178..f821b6cf39 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -308,6 +308,7 @@ "fillModelName": "Please fill in model name", "duplicateModelNameUnderProvider": "This provider already has a model with this name. Use a different name or edit the existing entry.", "invalidBaseUrlScheme": "API URL must start with http:// or https://", + "contextWindowTooSmall": "Context window must be at least 32K tokens", "saveFailed": "Save failed", "deleteFailed": "Failed to delete configuration", "loadFailed": "Failed to load AI configuration", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index b3c6a31500..12edf07590 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -308,6 +308,7 @@ "fillModelName": "请填写模型名称", "duplicateModelNameUnderProvider": "该服务商下已有同名模型,请改用其他名称或编辑已有条目", "invalidBaseUrlScheme": "API地址必须以 http:// 或 https:// 开头", + "contextWindowTooSmall": "上下文窗口大小不能小于 32K Tokens", "saveFailed": "保存失败", "deleteFailed": "删除配置失败", "loadFailed": "加载AI配置失败", diff --git a/src/web-ui/src/locales/zh-TW/settings/ai-model.json b/src/web-ui/src/locales/zh-TW/settings/ai-model.json index f86b954666..db8b02aa2b 100644 --- a/src/web-ui/src/locales/zh-TW/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-TW/settings/ai-model.json @@ -308,6 +308,7 @@ "fillModelName": "請填寫模型名稱", "duplicateModelNameUnderProvider": "該服務商下已有同名模型,請改用其他名稱或編輯已有條目", "invalidBaseUrlScheme": "API地址必須以 http:// 或 https:// 開頭", + "contextWindowTooSmall": "上下文視窗大小不能小於 32K Tokens", "saveFailed": "儲存失敗", "deleteFailed": "刪除設定失敗", "loadFailed": "載入AI設定失敗",