From d7e8a29dfe500242cf5074bfb933a08362a808d5 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:12:57 +0800 Subject: [PATCH 1/7] feat(ai): add local model context profile and runtime window probe - Probe Ollama, llama.cpp, LM Studio and vLLM runtime context windows, including reverse-proxy subpaths - Add context_window and context_profile=local: clamp budgets, compaction triggers, tool-output limits and always-visible tools - Count tool schemas in the soft input budget and apply a 10% token estimate margin for local tokenizers - Prefer max_tokens for local requests across agent, attack-chain, FOFA, workflow and HITL paths, and honor channel extra_request_fields - Recognize local context-overflow errors and retry with aggressive compaction --- internal/app/app.go | 1 + internal/attackchain/builder.go | 28 +- internal/config/config.go | 62 ++- internal/config/config_test.go | 49 ++ internal/config/context_profile.go | 339 +++++++++++++ internal/config/context_profile_test.go | 311 ++++++++++++ internal/handler/agent.go | 2 +- internal/handler/config.go | 158 ++++++- internal/handler/config_context_test.go | 94 ++++ internal/handler/fofa.go | 5 +- internal/handler/hitl_audit_agent.go | 5 +- internal/modelmeta/context.go | 444 ++++++++++++++++++ internal/modelmeta/context_test.go | 269 +++++++++++ internal/multiagent/context_budget.go | 87 +++- internal/multiagent/context_budget_test.go | 97 ++++ internal/multiagent/eino_adk_run_loop.go | 10 +- internal/multiagent/eino_agentic_summarize.go | 2 +- .../eino_chat_model_tail_middleware.go | 3 +- .../multiagent/eino_context_overflow_retry.go | 1 + internal/multiagent/eino_middleware.go | 14 +- internal/multiagent/eino_middleware_test.go | 12 + internal/multiagent/eino_model_resilience.go | 39 +- .../multiagent/eino_model_resilience_test.go | 79 ++++ internal/multiagent/eino_orchestration.go | 1 + internal/multiagent/eino_single_runner.go | 10 + internal/multiagent/eino_summarize.go | 24 +- .../multiagent/eino_summarize_payload_test.go | 42 ++ .../model_input_soft_budget_middleware.go | 4 +- internal/multiagent/runner.go | 31 +- internal/workflow/draft_generator.go | 9 +- 30 files changed, 2150 insertions(+), 82 deletions(-) create mode 100644 internal/config/context_profile.go create mode 100644 internal/config/context_profile_test.go create mode 100644 internal/handler/config_context_test.go create mode 100644 internal/modelmeta/context.go create mode 100644 internal/modelmeta/context_test.go diff --git a/internal/app/app.go b/internal/app/app.go index c7ce58969..b2f489f7d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1131,6 +1131,7 @@ func setupRoutes( protected.PUT("/config", configHandler.UpdateConfig) protected.POST("/config/apply", configHandler.ApplyConfig) protected.POST("/config/test-openai", configHandler.TestOpenAI) + protected.POST("/config/detect-context", configHandler.DetectContext) protected.POST("/config/test-typesafe", configHandler.TestTypeSafe) protected.POST("/config/test-vision", configHandler.TestVision) protected.POST("/config/list-models", configHandler.ListModels) diff --git a/internal/attackchain/builder.go b/internal/attackchain/builder.go index d466b47d9..a8f19172a 100644 --- a/internal/attackchain/builder.go +++ b/internal/attackchain/builder.go @@ -52,25 +52,10 @@ func NewBuilder(db *database.DB, openAIConfig *config.OpenAIConfig, logger *zap. } httpClient := &http.Client{Timeout: 5 * time.Minute, Transport: transport} - // 优先使用配置文件中的统一 Token 上限(config.yaml -> openai.max_total_tokens) - maxTokens := 0 - if openAIConfig != nil && openAIConfig.MaxTotalTokens > 0 { - maxTokens = openAIConfig.MaxTotalTokens - } else if openAIConfig != nil { - // 如果未显式配置 max_total_tokens,则根据模型设置一个合理的默认值 - model := strings.ToLower(openAIConfig.Model) - if strings.Contains(model, "gpt-4") { - maxTokens = 128000 // gpt-4通常支持128k - } else if strings.Contains(model, "gpt-3.5") { - maxTokens = 16000 // gpt-3.5-turbo通常支持16k - } else if strings.Contains(model, "deepseek") { - maxTokens = 131072 // deepseek-chat通常支持131k - } else { - maxTokens = 100000 // 兜底默认值 - } - } else { - // 没有 OpenAI 配置时使用兜底值,避免为 0 - maxTokens = 100000 + // 统一走通道的有效预算:已按 context_window / context_profile 收紧。 + maxTokens := 100000 + if openAIConfig != nil { + maxTokens = openAIConfig.EffectiveMaxTotalTokens() } return &Builder{ @@ -897,9 +882,10 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) ( "content": prompt, }, }, - "temperature": 0.3, - "max_completion_tokens": attackChainMaxCompletionTokens(b.maxTokens), + "temperature": 0.3, + b.openAIConfig.OutputLimitField(): attackChainMaxCompletionTokens(b.maxTokens), } + config.ApplyExtraRequestFields(requestBody, b.openAIConfig.Reasoning.ExtraRequestFields) var apiResponse struct { Choices []struct { diff --git a/internal/config/config.go b/internal/config/config.go index 2249919b0..5e2ffc02f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -281,6 +281,8 @@ type MultiAgentEinoMiddlewareConfig struct { ToolSearchAlwaysVisible int `yaml:"tool_search_always_visible,omitempty" json:"tool_search_always_visible,omitempty"` // default 12; first N tools stay always visible // ToolSearchAlwaysVisibleTools keeps specified tool names always visible (never hidden by tool_search). ToolSearchAlwaysVisibleTools []string `yaml:"tool_search_always_visible_tools,omitempty" json:"tool_search_always_visible_tools,omitempty"` + // ToolSearchIncludeBuiltins keeps backend builtin tools always visible (nil = true). + ToolSearchIncludeBuiltins *bool `yaml:"tool_search_include_builtins,omitempty" json:"tool_search_include_builtins,omitempty"` // Plantask adds TaskCreate/Get/Update/List (file-backed under skills dir); requires eino_skills + local backend. PlantaskEnable bool `yaml:"plantask_enable,omitempty" json:"plantask_enable,omitempty"` // PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask). @@ -488,6 +490,11 @@ func (c MultiAgentEinoMiddlewareConfig) PatchToolCallsEffective() bool { return true } +// ToolSearchIncludeBuiltinsEffective controls whether backend builtin tools bypass tool_search. +func (c MultiAgentEinoMiddlewareConfig) ToolSearchIncludeBuiltinsEffective() bool { + return c.ToolSearchIncludeBuiltins == nil || *c.ToolSearchIncludeBuiltins +} + // MultiAgentSubConfig 子代理(Eino ChatModelAgent):deep 下由 task 调度;supervisor 下由 transfer 委派;plan_execute 不使用子代理列表。 type MultiAgentSubConfig struct { ID string `yaml:"id" json:"id"` @@ -847,6 +854,10 @@ type OpenAIConfig struct { Model string `yaml:"model" json:"model"` MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"` MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"` + // ContextWindow 是模型原生上下文窗口(token);0 表示未知。用于校验/压缩 max_total_tokens。 + ContextWindow int `yaml:"context_window,omitempty" json:"context_window,omitempty"` + // ContextProfile 选择上下文预算档位:""/"auto" 通用;"local" 针对 32K-256K 本地模型收紧预算。 + ContextProfile string `yaml:"context_profile,omitempty" json:"context_profile,omitempty"` // Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(Eino 单/多代理路径生效)。 Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"` } @@ -866,6 +877,8 @@ type AIChannelConfig struct { Model string `yaml:"model" json:"model"` MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"` MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"` + ContextWindow int `yaml:"context_window,omitempty" json:"context_window,omitempty"` + ContextProfile string `yaml:"context_profile,omitempty" json:"context_profile,omitempty"` Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"` } @@ -881,6 +894,8 @@ func (c AIChannelConfig) ToOpenAIConfig() OpenAIConfig { Model: c.Model, MaxTotalTokens: c.MaxTotalTokens, MaxCompletionTokens: c.MaxCompletionTokens, + ContextWindow: c.ContextWindow, + ContextProfile: NormalizeContextProfile(c.ContextProfile), Reasoning: c.Reasoning, } } @@ -897,6 +912,8 @@ func AIChannelFromOpenAI(id, name string, oa OpenAIConfig) AIChannelConfig { Model: oa.Model, MaxTotalTokens: oa.MaxTotalTokens, MaxCompletionTokens: oa.MaxCompletionTokens, + ContextWindow: oa.ContextWindow, + ContextProfile: oa.ContextProfile, Reasoning: oa.Reasoning, } } @@ -978,10 +995,14 @@ func (c *Config) ApplyDefaultAIChannel() { } func (c OpenAIConfig) MaxCompletionTokensEffective() int { - if c.MaxCompletionTokens > 0 { - return c.MaxCompletionTokens + out := c.MaxCompletionTokens + if out <= 0 { + out = DefaultMaxCompletionTokens + } + if c.ContextWindow > 0 && out > c.ContextWindow/2 { + out = c.ContextWindow / 2 } - return DefaultMaxCompletionTokens + return out } // IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's @@ -1062,6 +1083,20 @@ func (c OpenAIReasoningConfig) ModeEffective() string { return m } +// ApplyExtraRequestFields merges admin-provided root JSON fields into a +// Chat Completions payload. Existing fields win, matching the Eino path. +func ApplyExtraRequestFields(payload map[string]interface{}, fields map[string]interface{}) { + if payload == nil || len(fields) == 0 { + return + } + for key, value := range fields { + if _, exists := payload[key]; exists { + continue + } + payload[key] = value + } +} + // ProfileEffective returns auto when empty. func (c OpenAIReasoningConfig) ProfileEffective() string { p := strings.ToLower(strings.TrimSpace(c.Profile)) @@ -1666,6 +1701,11 @@ func Load(path string) (*Config, error) { if err := validateOpenAIOutputLimits(cfg.OpenAI); err != nil { return nil, err } + for id, ch := range cfg.AI.Channels { + if err := ValidateContextSettings("ai.channels."+id, ch.ContextWindow, ch.ContextProfile); err != nil { + return nil, err + } + } // 如果配置了工具目录,从目录加载工具配置 if cfg.Security.ToolsDir != "" { inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...) @@ -1732,6 +1772,22 @@ func validateOpenAIOutputLimits(openAI OpenAIConfig) error { if openAI.MaxCompletionTokens < 0 { return fmt.Errorf("openai.max_completion_tokens 必须为正数") } + return ValidateContextSettings("openai", openAI.ContextWindow, openAI.ContextProfile) +} + +// ValidateContextSettings 校验主通道与 ai.channels 共用的上下文配置。 +func ValidateContextSettings(prefix string, window int, profile string) error { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "openai" + } + if window < 0 { + return fmt.Errorf("%s.context_window 不能为负数", prefix) + } + if raw := strings.TrimSpace(profile); raw != "" && + !strings.EqualFold(raw, "auto") && NormalizeContextProfile(raw) == "" { + return fmt.Errorf("%s.context_profile 只支持 auto 或 local", prefix) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 97a99c176..64736ddb2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -167,6 +167,8 @@ func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) { " api_key: deepseek-key", " model: deepseek-chat", " max_total_tokens: 64000", + " context_window: 131072", + " context_profile: local", "server:", " host: 127.0.0.1", " port: 8080", @@ -183,12 +185,59 @@ func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) { if cfg.OpenAI.Model != "deepseek-chat" || cfg.OpenAI.APIKey != "deepseek-key" || cfg.OpenAI.MaxTotalTokens != 64000 { t.Fatalf("runtime OpenAI config did not follow ai.default_channel: %+v", cfg.OpenAI) } + if cfg.OpenAI.ContextWindow != 131072 || cfg.OpenAI.ContextProfile != "local" { + t.Fatalf("context profile fields not loaded: %+v", cfg.OpenAI) + } oa, id, ok := cfg.ResolveAIChannel("qwen") if !ok || id != "qwen" || oa.Model != "qwen-max" || oa.APIKey != "qwen-key" { t.Fatalf("ResolveAIChannel(qwen) = (%+v, %q, %v)", oa, id, ok) } } +func TestLoadRejectsInvalidChannelContextSettings(t *testing.T) { + t.Parallel() + cases := []struct { + name string + profile string + window string + wantErr string + }{ + {name: "unknown profile", profile: "turbo", wantErr: "context_profile"}, + {name: "negative window", profile: "local", window: "-1", wantErr: "context_window"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + lines := []string{ + "ai:", + " default_channel: qwen", + " channels:", + " qwen:", + " base_url: https://example.invalid/v1", + " api_key: key", + " model: qwen-max", + } + if tc.profile != "" { + lines = append(lines, " context_profile: "+tc.profile) + } + if tc.window != "" { + lines = append(lines, " context_window: "+tc.window) + } + lines = append(lines, "") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Load error = %v, want containing %q", err, tc.wantErr) + } + }) + } +} + func TestNormalizeAIProviderProfilesForOfficialDeepSeekEndpoint(t *testing.T) { cfg := &Config{ OpenAI: OpenAIConfig{ diff --git a/internal/config/context_profile.go b/internal/config/context_profile.go new file mode 100644 index 000000000..5e5a400e9 --- /dev/null +++ b/internal/config/context_profile.go @@ -0,0 +1,339 @@ +package config + +import "strings" + +const ( + // DefaultMaxTotalTokens 是未显式配置时的通用上下文预算。 + DefaultMaxTotalTokens = 120000 + // DefaultLocalContextWindow 是 local 档位下未提供 context_window 时的保守窗口。 + DefaultLocalContextWindow = 32768 + // LocalContextBudgetPercent 是 local 档位把原生窗口折算成提示词预算的比例。 + LocalContextBudgetPercent = 60 + // LocalContextBudgetCap 防止 256K/1M 窗口把预算放大到超出本地模型有效上下文。 + LocalContextBudgetCap = 131072 + // MinLocalContextWindow 以下不建议运行带工具的 agent。 + MinLocalContextWindow = 16384 + // LocalTokenEstimateSafetyPercent 是 local 档位对 token 估算的保守放大比例。 + // 本地 tokenizer 与 tiktoken 不一致(CJK 尤甚),留出余量避免踩到运行时窗口。 + LocalTokenEstimateSafetyPercent = 110 + + ContextWarningLocalWindowUnknown = "local_context_window_unknown" + ContextWarningLocalWindowSmall = "local_context_window_small" + ContextWarningMaxTotalClamped = "max_total_tokens_clamped" +) + +// NormalizeContextProfile 只接受 auto(空)与 local;未知值按 auto 处理,避免配置拼写错误放大预算。 +func NormalizeContextProfile(raw string) string { + if strings.EqualFold(strings.TrimSpace(raw), "local") { + return "local" + } + return "" +} + +func (c OpenAIConfig) IsLocalContextProfile() bool { + return NormalizeContextProfile(c.ContextProfile) == "local" +} + +// TokenEstimateSafetyPercent 供 token 计数器使用:local 档位放大估算,auto 保持原值。 +func (c OpenAIConfig) TokenEstimateSafetyPercent() int { + if c.IsLocalContextProfile() { + return LocalTokenEstimateSafetyPercent + } + return 100 +} + +// OutputLimitField 返回输出上限字段名。本地端点普遍只认旧字段 max_tokens。 +func (c OpenAIConfig) OutputLimitField() string { + if c.IsLocalContextProfile() { + return "max_tokens" + } + return "max_completion_tokens" +} + +// EffectiveContextWindow 返回用于预算计算的原生窗口;local 档位缺省时按保守值处理。 +func (c OpenAIConfig) EffectiveContextWindow() int { + if c.ContextWindow > 0 { + return c.ContextWindow + } + if c.IsLocalContextProfile() { + return DefaultLocalContextWindow + } + return 0 +} + +// EffectiveMaxTotalTokens 把 max_total_tokens 收紧到模型窗口内。 +// 通用档位保留输出预留;local 档位再按 Effective Context 经验折到窗口的 60%,并限制在 128K 以内。 +func (c OpenAIConfig) EffectiveMaxTotalTokens() int { + configured := c.MaxTotalTokens + if configured <= 0 { + configured = DefaultMaxTotalTokens + } + window := c.EffectiveContextWindow() + if window <= 0 { + return configured + } + + reserve := c.MaxCompletionTokensEffective() + if reserve > window/2 { + reserve = window / 2 + } + hard := window - reserve + if hard < 4096 { + hard = window * 85 / 100 + } + if hard < 1024 { + hard = 1024 + } + + budget := configured + if budget > hard { + budget = hard + } + if c.IsLocalContextProfile() { + local := window * LocalContextBudgetPercent / 100 + if local > LocalContextBudgetCap { + local = LocalContextBudgetCap + } + if local < 8192 && window >= 8192 { + local = 8192 + } + if local > hard { + local = hard + } + if budget > local { + budget = local + } + } + if budget < 1024 { + budget = 1024 + } + return budget +} + +// ContextBudgetWarnings 返回可本地化的告警码;不直接拼自然语言,便于 UI 多语言与日志检索。 +func (c OpenAIConfig) ContextBudgetWarnings() []string { + var out []string + if c.IsLocalContextProfile() { + if c.ContextWindow <= 0 { + out = append(out, ContextWarningLocalWindowUnknown) + } else if c.ContextWindow < MinLocalContextWindow { + out = append(out, ContextWarningLocalWindowSmall) + } + } + configured := c.MaxTotalTokens + if configured <= 0 { + configured = DefaultMaxTotalTokens + } + if c.EffectiveContextWindow() > 0 && configured > c.EffectiveMaxTotalTokens() { + out = append(out, ContextWarningMaxTotalClamped) + } + return out +} + +// WithEffectiveContextBudget 返回运行期副本:预算与 local 档位中间件参数都在这里收口。 +func (c *Config) WithEffectiveContextBudget() *Config { + if c == nil { + return nil + } + out := *c + out.OpenAI.MaxTotalTokens = c.OpenAI.EffectiveMaxTotalTokens() + out.MultiAgent.EinoMiddleware = EffectiveEinoMiddlewareForChannel(c.MultiAgent.EinoMiddleware, c.OpenAI) + return &out +} + +// EffectiveEinoMiddlewareForChannel 在 local 档位下只收紧参数,不放大用户已经设得更小的值。 +// 档位是显式 opt-in;希望完全自定义时使用 auto。 +func EffectiveEinoMiddlewareForChannel(mw MultiAgentEinoMiddlewareConfig, oa OpenAIConfig) MultiAgentEinoMiddlewareConfig { + if !oa.IsLocalContextProfile() { + return mw + } + window := oa.EffectiveContextWindow() + budget := oa.EffectiveMaxTotalTokens() + out := mw + + triggerRatio := mw.SummarizationTriggerRatioEffective() + if triggerRatio > 0.85 { + triggerRatio = 0.85 + } + out.SummarizationTriggerRatio = triggerRatio + + out.SummarizationOutputReserveTokens = tightenInt( + mw.SummarizationOutputReserveTokensEffective(), + localSummaryOutputReserve(window, oa.MaxCompletionTokensEffective()), + ) + out.ReductionMaxLengthForTrunc = tightenInt( + mw.ReductionMaxLengthForTruncEffective(), + localToolTruncBytes(window), + ) + out.ReductionMaxTokensForClear = tightenInt( + mw.ReductionMaxTokensForClearEffective(), + localToolClearTokens(budget), + ) + out.ReductionEnable = true + out.ReductionSubAgents = true + + out.SummarizationUserIntentLedgerMaxRunes = tightenInt( + mw.SummarizationUserIntentLedgerMaxRunesEffective(), + localLedgerRunes(budget), + ) + out.SummarizationUserIntentLedgerEntryMaxRunes = tightenInt( + mw.SummarizationUserIntentLedgerEntryMaxRunesEffective(), + localLedgerEntryRunes(out.SummarizationUserIntentLedgerMaxRunes), + ) + + out.LatestUserMessageMaxRunes = tightenInt( + mw.LatestUserMessageMaxRunesEffective(), + localLatestUserRunes(budget), + ) + out.LatestUserMessageHeadRunes = tightenInt( + mw.LatestUserMessageHeadRunesEffective(), + out.LatestUserMessageMaxRunes/2, + ) + out.LatestUserMessageTailRunes = tightenInt( + mw.LatestUserMessageTailRunesEffective(), + out.LatestUserMessageMaxRunes-out.LatestUserMessageHeadRunes, + ) + + out.ToolSearchEnable = true + if mw.ToolSearchMinTools <= 0 || mw.ToolSearchMinTools > 8 { + out.ToolSearchMinTools = 8 + } + if mw.ToolSearchAlwaysVisible <= 0 || mw.ToolSearchAlwaysVisible > 8 { + out.ToolSearchAlwaysVisible = 8 + } + out.ToolSearchAlwaysVisibleTools = trimLocalAlwaysVisibleTools(mw.ToolSearchAlwaysVisibleTools) + includeBuiltins := false + out.ToolSearchIncludeBuiltins = &includeBuiltins + + out.PlanExecuteMaxStepResultRunes = tightenInt( + mw.PlanExecuteMaxStepResultRunesEffective(), + 2000, + ) + return out +} + +func tightenInt(current, ceil int) int { + if ceil <= 0 { + return current + } + if current <= 0 || current > ceil { + return ceil + } + return current +} + +func localSummaryOutputReserve(window, maxCompletion int) int { + reserve := window / 32 + if reserve < 2048 { + reserve = 2048 + } + if reserve > 8192 { + reserve = 8192 + } + if maxCompletion > 0 && reserve > maxCompletion { + reserve = maxCompletion + } + return reserve +} + +func localToolTruncBytes(window int) int { + bytes := window / 6 + if bytes < 8192 { + bytes = 8192 + } + if bytes > 32768 { + bytes = 32768 + } + if window > 0 && bytes > window { + bytes = window + } + return bytes +} + +func localToolClearTokens(budget int) int { + tokens := budget * 35 / 100 + if tokens < 4096 { + tokens = 4096 + } + if budget > 0 && tokens > budget { + tokens = budget + } + return tokens +} + +func localLedgerRunes(budget int) int { + runes := budget * 2 + if runes > DefaultSummarizationUserIntentLedgerMaxRunes { + runes = DefaultSummarizationUserIntentLedgerMaxRunes + } + if runes < 4096 { + runes = 4096 + } + return runes +} + +func localLedgerEntryRunes(ledgerMax int) int { + runes := ledgerMax / 6 + if runes > DefaultSummarizationUserIntentLedgerEntryMaxRunes { + runes = DefaultSummarizationUserIntentLedgerEntryMaxRunes + } + if runes < 1024 { + runes = 1024 + } + return runes +} + +func localLatestUserRunes(budget int) int { + runes := budget * 2 + if runes > DefaultLatestUserMessageMaxRunes { + runes = DefaultLatestUserMessageMaxRunes + } + if runes < 4096 { + runes = 4096 + } + return runes +} + +// localCoreToolNames 是本地小窗口下值得常驻的工具;其余交给 tool_search 按需解锁。 +var localCoreToolNames = map[string]struct{}{ + "read_file": {}, + "glob": {}, + "grep": {}, + "write_file": {}, + "edit_file": {}, + "execute": {}, + "exec": {}, + "task": {}, + "transfer_to_agent": {}, + "exit": {}, + "write_todos": {}, + "skill": {}, + "tool_search": {}, + "record_vulnerability": {}, + "get_tool_execution": {}, + "wait_tool_execution": {}, + "analyze_image": {}, + "search_knowledge_base": {}, + "http-framework-test": {}, +} + +func trimLocalAlwaysVisibleTools(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(localCoreToolNames)) + seen := make(map[string]struct{}, len(in)) + for _, raw := range in { + name := strings.TrimSpace(raw) + key := strings.ToLower(name) + if _, ok := localCoreToolNames[key]; !ok { + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, name) + } + return out +} diff --git a/internal/config/context_profile_test.go b/internal/config/context_profile_test.go new file mode 100644 index 000000000..bbb83f502 --- /dev/null +++ b/internal/config/context_profile_test.go @@ -0,0 +1,311 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestTokenEstimateSafetyPercent(t *testing.T) { + t.Parallel() + if got := (OpenAIConfig{}).TokenEstimateSafetyPercent(); got != 100 { + t.Fatalf("auto safety percent = %d, want 100", got) + } + if got := (OpenAIConfig{ContextProfile: "local"}).TokenEstimateSafetyPercent(); got != LocalTokenEstimateSafetyPercent { + t.Fatalf("local safety percent = %d, want %d", got, LocalTokenEstimateSafetyPercent) + } +} + +func TestApplyExtraRequestFieldsKeepsCoreFields(t *testing.T) { + t.Parallel() + payload := map[string]interface{}{"model": "qwen3", "temperature": 0.1} + ApplyExtraRequestFields(payload, map[string]interface{}{ + "model": "override", + "chat_template_kwargs": map[string]interface{}{"enable_thinking": false}, + "top_p": 0.9, + }) + if payload["model"] != "qwen3" { + t.Fatalf("core field overridden: %#v", payload) + } + if payload["top_p"] != 0.9 { + t.Fatalf("extra field missing: %#v", payload) + } + if _, ok := payload["chat_template_kwargs"]; !ok { + t.Fatalf("chat_template_kwargs missing: %#v", payload) + } + ApplyExtraRequestFields(nil, map[string]interface{}{"x": 1}) +} + +func TestOutputLimitField(t *testing.T) { + t.Parallel() + if got := (OpenAIConfig{}).OutputLimitField(); got != "max_completion_tokens" { + t.Fatalf("auto output field = %q", got) + } + if got := (OpenAIConfig{ContextProfile: "local"}).OutputLimitField(); got != "max_tokens" { + t.Fatalf("local output field = %q", got) + } +} + +func TestEffectiveMaxTotalTokens(t *testing.T) { + t.Parallel() + cases := []struct { + name string + oa OpenAIConfig + want int + }{ + { + name: "unknown window keeps configured", + oa: OpenAIConfig{MaxTotalTokens: 64000}, + want: 64000, + }, + { + name: "unknown window default 120k", + oa: OpenAIConfig{}, + want: 120000, + }, + { + name: "remote window reserves output", + oa: OpenAIConfig{ContextWindow: 131072, MaxTotalTokens: 120000}, + want: 114688, + }, + { + name: "remote explicit smaller budget wins", + oa: OpenAIConfig{ContextWindow: 131072, MaxTotalTokens: 64000}, + want: 64000, + }, + { + name: "local 128k uses 60 percent", + oa: OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 120000}, + want: 78643, + }, + { + name: "local explicit smaller budget wins", + oa: OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 20000}, + want: 20000, + }, + { + name: "local 32k reserves half for output", + oa: OpenAIConfig{ContextWindow: 32768, ContextProfile: "local", MaxTotalTokens: 120000}, + want: 16384, + }, + { + name: "local 32k keeps configured output", + oa: OpenAIConfig{ContextWindow: 32768, ContextProfile: "local", MaxTotalTokens: 120000, MaxCompletionTokens: 4096}, + want: 19660, + }, + { + name: "local missing window assumes 32k", + oa: OpenAIConfig{ContextProfile: "local", MaxTotalTokens: 120000}, + want: 16384, + }, + { + name: "local 256k caps budget", + oa: OpenAIConfig{ContextWindow: 262144, ContextProfile: "local", MaxTotalTokens: 200000}, + want: 131072, + }, + { + name: "local 8k stays within window", + oa: OpenAIConfig{ContextWindow: 8192, ContextProfile: "local", MaxTotalTokens: 120000}, + want: 4096, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.oa.EffectiveMaxTotalTokens(); got != tc.want { + t.Fatalf("EffectiveMaxTotalTokens() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestMaxCompletionTokensEffectiveClampsToWindow(t *testing.T) { + t.Parallel() + cases := []struct { + name string + oa OpenAIConfig + want int + }{ + {name: "no window uses configured", oa: OpenAIConfig{MaxCompletionTokens: 32768}, want: 32768}, + {name: "no window uses default", oa: OpenAIConfig{}, want: DefaultMaxCompletionTokens}, + {name: "32k window halves output", oa: OpenAIConfig{ContextWindow: 32768, MaxCompletionTokens: 32768}, want: 16384}, + {name: "128k window keeps output", oa: OpenAIConfig{ContextWindow: 131072, MaxCompletionTokens: 16384}, want: 16384}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.oa.MaxCompletionTokensEffective(); got != tc.want { + t.Fatalf("MaxCompletionTokensEffective() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestContextBudgetWarnings(t *testing.T) { + t.Parallel() + cases := []struct { + name string + oa OpenAIConfig + want []string + }{ + { + name: "no window no warning", + oa: OpenAIConfig{MaxTotalTokens: 120000}, + }, + { + name: "local missing window warns twice", + oa: OpenAIConfig{ContextProfile: "local", MaxTotalTokens: 120000}, + want: []string{ContextWarningLocalWindowUnknown, ContextWarningMaxTotalClamped}, + }, + { + name: "local tiny window warns", + oa: OpenAIConfig{ContextProfile: "local", ContextWindow: 8192}, + want: []string{ContextWarningLocalWindowSmall, ContextWarningMaxTotalClamped}, + }, + { + name: "remote oversized budget clamps", + oa: OpenAIConfig{ContextWindow: 32768, MaxTotalTokens: 64000}, + want: []string{ContextWarningMaxTotalClamped}, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.oa.ContextBudgetWarnings(); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ContextBudgetWarnings() = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestEffectiveEinoMiddlewareForChannelLocalTightens(t *testing.T) { + t.Parallel() + mw := MultiAgentEinoMiddlewareConfig{ + SummarizationTriggerRatio: 0.8, + SummarizationOutputReserveTokens: 40960, + ReductionMaxLengthForTrunc: 100000, + ReductionMaxTokensForClear: 60000, + SummarizationUserIntentLedgerMaxRunes: 96000, + SummarizationUserIntentLedgerEntryMaxRunes: 16000, + LatestUserMessageMaxRunes: 48000, + LatestUserMessageHeadRunes: 24000, + LatestUserMessageTailRunes: 24000, + ToolSearchAlwaysVisible: 12, + PlanExecuteMaxStepResultRunes: 4000, + ToolSearchAlwaysVisibleTools: []string{ + "read_file", "glob", "grep", "analyze_image", "write_file", "edit_file", + "execute", "task", "transfer_to_agent", "exit", "write_todos", "skill", + "tool_search", "record_vulnerability", "http-framework-test", "webshell_exec", + }, + } + oa := OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 120000} + out := EffectiveEinoMiddlewareForChannel(mw, oa) + + if out.SummarizationTriggerRatio != 0.8 { + t.Fatalf("trigger ratio = %v, want 0.8", out.SummarizationTriggerRatio) + } + if out.SummarizationOutputReserveTokens != 4096 { + t.Fatalf("output reserve = %d, want 4096", out.SummarizationOutputReserveTokens) + } + if out.ReductionMaxLengthForTrunc != 21845 { + t.Fatalf("tool trunc bytes = %d, want 21845", out.ReductionMaxLengthForTrunc) + } + if out.ReductionMaxTokensForClear != 27525 { + t.Fatalf("tool clear tokens = %d, want 27525", out.ReductionMaxTokensForClear) + } + if out.SummarizationUserIntentLedgerMaxRunes != 96000 { + t.Fatalf("ledger runes = %d, want 96000", out.SummarizationUserIntentLedgerMaxRunes) + } + if out.SummarizationUserIntentLedgerEntryMaxRunes != 16000 { + t.Fatalf("ledger entry runes = %d, want 16000", out.SummarizationUserIntentLedgerEntryMaxRunes) + } + if out.LatestUserMessageMaxRunes != 48000 { + t.Fatalf("latest user runes = %d, want 48000", out.LatestUserMessageMaxRunes) + } + if out.ToolSearchMinTools != 8 || out.ToolSearchAlwaysVisible != 8 { + t.Fatalf("tool search = (%d,%d), want (8,8)", out.ToolSearchMinTools, out.ToolSearchAlwaysVisible) + } + wantTools := []string{ + "read_file", "glob", "grep", "analyze_image", "write_file", "edit_file", "execute", "task", + "transfer_to_agent", "exit", "write_todos", "skill", "tool_search", + "record_vulnerability", "http-framework-test", + } + if !reflect.DeepEqual(out.ToolSearchAlwaysVisibleTools, wantTools) { + t.Fatalf("always visible tools = %#v, want %#v", out.ToolSearchAlwaysVisibleTools, wantTools) + } + if out.ToolSearchIncludeBuiltinsEffective() { + t.Fatal("local profile must let backend builtins go through tool_search") + } + if out.PlanExecuteMaxStepResultRunes != 2000 { + t.Fatalf("plan step result runes = %d, want 2000", out.PlanExecuteMaxStepResultRunes) + } + if !out.ReductionEnable || !out.ReductionSubAgents { + t.Fatal("local profile must enable reduction for main agent and sub-agents") + } +} + +func TestEffectiveEinoMiddlewareForChannelLocalNeverLoosens(t *testing.T) { + t.Parallel() + mw := MultiAgentEinoMiddlewareConfig{ + SummarizationTriggerRatio: 0.6, + SummarizationOutputReserveTokens: 1024, + ReductionMaxLengthForTrunc: 4096, + ReductionMaxTokensForClear: 2048, + SummarizationUserIntentLedgerMaxRunes: 8000, + SummarizationUserIntentLedgerEntryMaxRunes: 2000, + LatestUserMessageMaxRunes: 8000, + LatestUserMessageHeadRunes: 4000, + LatestUserMessageTailRunes: 4000, + ToolSearchMinTools: 4, + ToolSearchAlwaysVisible: 4, + PlanExecuteMaxStepResultRunes: 1000, + } + oa := OpenAIConfig{ContextWindow: 131072, ContextProfile: "local"} + out := EffectiveEinoMiddlewareForChannel(mw, oa) + + if out.SummarizationTriggerRatio != 0.6 || + out.SummarizationOutputReserveTokens != 1024 || + out.ReductionMaxLengthForTrunc != 4096 || + out.ReductionMaxTokensForClear != 2048 || + out.SummarizationUserIntentLedgerMaxRunes != 8000 || + out.SummarizationUserIntentLedgerEntryMaxRunes != 1333 || + out.LatestUserMessageMaxRunes != 8000 || + out.ToolSearchMinTools != 4 || + out.ToolSearchAlwaysVisible != 4 || + out.PlanExecuteMaxStepResultRunes != 1000 { + t.Fatalf("local profile loosened explicit settings: %+v", out) + } +} + +func TestEffectiveEinoMiddlewareForChannelAutoUnchanged(t *testing.T) { + t.Parallel() + mw := MultiAgentEinoMiddlewareConfig{ + SummarizationTriggerRatio: 0.8, + ToolSearchAlwaysVisibleTools: []string{"read_file", "webshell_exec"}, + } + if got := EffectiveEinoMiddlewareForChannel(mw, OpenAIConfig{}); !reflect.DeepEqual(got, mw) { + t.Fatalf("auto profile changed middleware: %+v", got) + } +} + +func TestWithEffectiveContextBudgetDoesNotMutateSource(t *testing.T) { + t.Parallel() + cfg := &Config{ + OpenAI: OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 120000}, + MultiAgent: MultiAgentConfig{ + EinoMiddleware: MultiAgentEinoMiddlewareConfig{SummarizationTriggerRatio: 0.8}, + }, + } + out := cfg.WithEffectiveContextBudget() + if cfg.OpenAI.MaxTotalTokens != 120000 || cfg.MultiAgent.EinoMiddleware.SummarizationTriggerRatio != 0.8 { + t.Fatalf("source config mutated: %+v", cfg) + } + if out.OpenAI.MaxTotalTokens != 78643 { + t.Fatalf("effective budget = %d, want 78643", out.OpenAI.MaxTotalTokens) + } + if out.MultiAgent.EinoMiddleware.SummarizationTriggerRatio != 0.8 { + t.Fatalf("effective trigger ratio = %v, want 0.8", out.MultiAgent.EinoMiddleware.SummarizationTriggerRatio) + } +} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 3c21fa26b..1cfb4e168 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -424,7 +424,7 @@ func (h *AgentHandler) configForAIChannel(channelID string) (*config.Config, str } cfgCopy := *h.config cfgCopy.OpenAI = oa - return &cfgCopy, resolvedID, nil + return cfgCopy.WithEffectiveContextBudget(), resolvedID, nil } func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent { diff --git a/internal/handler/config.go b/internal/handler/config.go index d36c8e55d..de44273ac 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -21,6 +21,7 @@ import ( "cyberstrike-ai/internal/llm" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" + "cyberstrike-ai/internal/modelmeta" "cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/security" "cyberstrike-ai/internal/toolguard" @@ -806,6 +807,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.OpenAI != nil { + if err := config.ValidateContextSettings("openai", req.OpenAI.ContextWindow, req.OpenAI.ContextProfile); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + if req.AI != nil { + for id, ch := range req.AI.Channels { + if err := config.ValidateContextSettings("ai.channels."+id, ch.ContextWindow, ch.ContextProfile); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + } // 更新OpenAI配置 if req.AI != nil { @@ -1268,6 +1283,116 @@ type TestOpenAIRequest struct { BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` +} + +// DetectContextRequest probes model metadata without issuing a completion. +type DetectContextRequest struct { + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` +} + +func probeModelContext(ctx context.Context, provider, baseURL, apiKey, model string) modelmeta.ContextInfo { + if llm.IsClaudeProvider(provider) { + return modelmeta.ContextInfo{} + } + probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + return modelmeta.DetectContext(probeCtx, baseURL, model, apiKey, nil) +} + +func addModelContextToResponse(resp gin.H, info modelmeta.ContextInfo, maxTotalTokens int, contextProfile string) { + if info.Backend != "" { + resp["context_backend"] = info.Backend + } + if info.ContextWindow > 0 { + resp["context_window"] = info.ContextWindow + } + if info.Source != "" { + resp["context_source"] = info.Source + } + if info.Loaded { + resp["context_loaded"] = true + } + if info.RuntimeUnknown { + resp["context_runtime_unknown"] = true + } + profile := config.NormalizeContextProfile(contextProfile) + oa := config.OpenAIConfig{ + ContextWindow: info.ContextWindow, + ContextProfile: profile, + MaxTotalTokens: maxTotalTokens, + } + if info.ContextWindow > 0 || profile != "" { + resp["effective_max_total_tokens"] = oa.EffectiveMaxTotalTokens() + } + warnings := append([]string(nil), info.Warnings...) + if info.ContextWindow > 0 && maxTotalTokens > info.ContextWindow { + warnings = append(warnings, modelmeta.WarningBudgetExceedsWindow) + } + warnings = append(warnings, oa.ContextBudgetWarnings()...) + if len(warnings) > 0 { + resp["context_warnings"] = dedupeContextWarnings(warnings) + } +} + +func dedupeContextWarnings(warnings []string) []string { + out := make([]string, 0, len(warnings)) + seen := make(map[string]struct{}, len(warnings)) + for _, code := range warnings { + code = strings.TrimSpace(code) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + out = append(out, code) + } + return out +} + +// DetectContext probes local inference metadata (context window, runtime source). +func (h *ConfigHandler) DetectContext(c *gin.Context) { + var req DetectContextRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + req.APIKey = resolvedKey + if llm.IsClaudeProvider(req.Provider) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "supported": false, + "error": "Claude (Anthropic Messages API) 不提供上下文窗口探测,请手动填写", + }) + return + } + if strings.TrimSpace(req.Model) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "模型不能为空"}) + return + } + baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/") + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + info := probeModelContext(c.Request.Context(), req.Provider, baseURL, req.APIKey, req.Model) + resp := gin.H{"success": true, "model": req.Model} + addModelContextToResponse(resp, info, req.MaxTotalTokens, req.ContextProfile) + c.JSON(http.StatusOK, resp) } // TestOpenAI 测试OpenAI API连接是否可用 @@ -1302,13 +1427,14 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { } } - // 构造一个最小的 chat completion 请求 + // 构造一个最小的 chat completion 请求;本地档位使用更通用的 max_tokens。 + maxTokensField := (config.OpenAIConfig{ContextProfile: req.ContextProfile}).OutputLimitField() payload := map[string]interface{}{ "model": req.Model, "messages": []map[string]string{ {"role": "user", "content": "Hi"}, }, - "max_completion_tokens": 5, + maxTokensField: 5, } // OpenAI-compatible 通道使用内部客户端;Claude 通道在下方直接使用 Eino agenticclaude。 @@ -1392,11 +1518,13 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "success": true, "model": chatResp.Model, "latency_ms": latency.Milliseconds(), - }) + } + addModelContextToResponse(resp, probeModelContext(c.Request.Context(), req.Provider, baseURL, req.APIKey, req.Model), req.MaxTotalTokens, req.ContextProfile) + c.JSON(http.StatusOK, resp) } // TestTypeSafeRequest 测试 TypeSafe / Jev 连接。 @@ -1595,7 +1723,7 @@ func (h *ConfigHandler) TestVision(c *gin.Context) { "messages": []map[string]string{ {"role": "user", "content": "Hi"}, }, - "max_completion_tokens": 5, + oa.OutputLimitField(): 5, } tmpCfg := &config.OpenAIConfig{ @@ -2153,6 +2281,16 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) { if cfg.MaxTotalTokens > 0 { setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens) } + if cfg.ContextWindow > 0 { + setIntInMap(openaiNode, "context_window", cfg.ContextWindow) + } else { + removeKeyFromMap(openaiNode, "context_window") + } + if profile := config.NormalizeContextProfile(cfg.ContextProfile); profile != "" { + setStringInMap(openaiNode, "context_profile", profile) + } else { + removeKeyFromMap(openaiNode, "context_profile") + } rn := ensureMap(openaiNode, "reasoning") if strings.TrimSpace(cfg.Reasoning.Mode) != "" { setStringInMap(rn, "mode", cfg.Reasoning.Mode) @@ -2212,6 +2350,16 @@ func updateAIConfig(doc *yaml.Node, cfg config.AIConfig) { if ch.MaxCompletionTokens > 0 { setIntInMap(channelNode, "max_completion_tokens", ch.MaxCompletionTokens) } + if ch.ContextWindow > 0 { + setIntInMap(channelNode, "context_window", ch.ContextWindow) + } else { + removeKeyFromMap(channelNode, "context_window") + } + if profile := config.NormalizeContextProfile(ch.ContextProfile); profile != "" { + setStringInMap(channelNode, "context_profile", profile) + } else { + removeKeyFromMap(channelNode, "context_profile") + } rn := ensureMap(channelNode, "reasoning") if strings.TrimSpace(ch.Reasoning.Mode) != "" { setStringInMap(rn, "mode", ch.Reasoning.Mode) diff --git a/internal/handler/config_context_test.go b/internal/handler/config_context_test.go new file mode 100644 index 000000000..5421306f5 --- /dev/null +++ b/internal/handler/config_context_test.go @@ -0,0 +1,94 @@ +package handler + +import ( + "testing" + + "cyberstrike-ai/internal/modelmeta" + + "github.com/gin-gonic/gin" +) + +func TestAddModelContextToResponseLocalProfilePreview(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{ + Backend: modelmeta.BackendOllama, + ContextWindow: 131072, + Source: "ollama_ps", + Loaded: true, + }, 120000, "local") + + if got := resp["context_window"]; got != 131072 { + t.Fatalf("context_window = %#v, want 131072", got) + } + if got := resp["effective_max_total_tokens"]; got != 78643 { + t.Fatalf("effective_max_total_tokens = %#v, want 78643", got) + } + warnings, ok := resp["context_warnings"].([]string) + if !ok { + t.Fatalf("context_warnings type = %T", resp["context_warnings"]) + } + if !containsWarning(warnings, "max_total_tokens_clamped") { + t.Fatalf("missing clamp warning: %#v", warnings) + } + if countWarning(warnings, "max_total_tokens_clamped") != 1 { + t.Fatalf("clamp warning duplicated: %#v", warnings) + } +} + +func TestAddModelContextToResponseLocalUnknownWindowAssumes32K(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, "local") + if got := resp["effective_max_total_tokens"]; got != 16384 { + t.Fatalf("effective_max_total_tokens = %#v, want 16384", got) + } + warnings := resp["context_warnings"].([]string) + if !containsWarning(warnings, "local_context_window_unknown") { + t.Fatalf("missing unknown-window warning: %#v", warnings) + } +} + +func TestAddModelContextToResponseAutoProfileKeepsNoPreviewWithoutWindow(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, "auto") + if _, ok := resp["effective_max_total_tokens"]; ok { + t.Fatalf("auto profile without window should not preview a budget: %#v", resp) + } +} + +func TestAddModelContextToResponseDedupesWarnings(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{ + ContextWindow: 32768, + Warnings: []string{modelmeta.WarningContextUnknown, modelmeta.WarningContextUnknown}, + }, 64000, "auto") + warnings := resp["context_warnings"].([]string) + if countWarning(warnings, modelmeta.WarningContextUnknown) != 1 { + t.Fatalf("warnings not deduped: %#v", warnings) + } + if !containsWarning(warnings, modelmeta.WarningBudgetExceedsWindow) { + t.Fatalf("missing budget-exceeds-window warning: %#v", warnings) + } +} + +func containsWarning(warnings []string, want string) bool { + for _, code := range warnings { + if code == want { + return true + } + } + return false +} + +func countWarning(warnings []string, want string) int { + count := 0 + for _, code := range warnings { + if code == want { + count++ + } + } + return count +} diff --git a/internal/handler/fofa.go b/internal/handler/fofa.go index 66545d02b..08e909e6a 100644 --- a/internal/handler/fofa.go +++ b/internal/handler/fofa.go @@ -418,9 +418,10 @@ Shodan 官方查询语法参考: {"role": "system", "content": systemPrompt}, {"role": "user", "content": userPrompt}, }, - "temperature": 0.1, - "max_completion_tokens": 12000, + "temperature": 0.1, + h.cfg.OpenAI.OutputLimitField(): 12000, } + config.ApplyExtraRequestFields(requestBody, h.cfg.OpenAI.Reasoning.ExtraRequestFields) // OpenAI 返回结构:只需要 choices[0].message.content var apiResponse struct { diff --git a/internal/handler/hitl_audit_agent.go b/internal/handler/hitl_audit_agent.go index 4da67c750..53aee26c8 100644 --- a/internal/handler/hitl_audit_agent.go +++ b/internal/handler/hitl_audit_agent.go @@ -49,11 +49,12 @@ func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName {"role": "system", "content": prompt}, {"role": "user", "content": userContent}, }, - "temperature": 0.1, - "max_completion_tokens": 1024, + "temperature": 0.1, + llmCfg.OutputLimitField(): 1024, // 审计裁决需要结构化 JSON;关闭 thinking 避免 Qwen 等把正文放进 reasoning_content 导致解析失败。 "thinking": map[string]interface{}{"type": "disabled"}, } + config.ApplyExtraRequestFields(requestBody, llmCfg.Reasoning.ExtraRequestFields) var apiResponse struct { Choices []struct { diff --git a/internal/modelmeta/context.go b/internal/modelmeta/context.go new file mode 100644 index 000000000..064d01f8d --- /dev/null +++ b/internal/modelmeta/context.go @@ -0,0 +1,444 @@ +// Package modelmeta probes local inference servers for runtime model metadata. +// It is intentionally best-effort: callers treat an empty ContextWindow as "unknown". +package modelmeta + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + BackendOllama = "ollama" + BackendLlamaCpp = "llamacpp" + BackendLMStudio = "lmstudio" + BackendOpenAICompatible = "openai_compatible" + + WarningOllamaV1IgnoresNumCtx = "ollama_v1_ignores_num_ctx" + WarningOllamaRuntimeUnknown = "ollama_runtime_context_unknown" + WarningRuntimeUnknown = "context_runtime_unknown" + WarningContextUnknown = "context_window_unknown" + WarningBudgetExceedsWindow = "configured_budget_exceeds_context_window" + probeTimeout = 2 * time.Second +) + +type ContextInfo struct { + Backend string `json:"backend,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + Source string `json:"context_source,omitempty"` + Loaded bool `json:"context_loaded,omitempty"` + RuntimeUnknown bool `json:"context_runtime_unknown,omitempty"` + Warnings []string `json:"context_warnings,omitempty"` +} + +// DetectContext probes the endpoint in local-backend-first order and returns the +// most authoritative context window it can find. It never returns an error: a +// failed probe is represented by an empty ContextWindow plus a warning. +func DetectContext(ctx context.Context, baseURL, model, apiKey string, client *http.Client) ContextInfo { + if ctx == nil { + ctx = context.Background() + } + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + parsed, err := parseBaseURL(baseURL) + if err != nil { + return ContextInfo{Warnings: []string{WarningContextUnknown}} + } + base := strings.TrimSuffix(parsed.String(), "/") + model = strings.TrimSpace(model) + + for _, root := range probeRoots(parsed) { + if info, ok := detectOllama(ctx, client, root, base, model, apiKey); ok { + return info + } + if info, ok := detectLlamaCpp(ctx, client, root, model, apiKey); ok { + return info + } + if info, ok := detectLMStudio(ctx, client, root, model, apiKey); ok { + return info + } + } + return detectOpenAICompatible(ctx, client, base, model, apiKey) +} + +// probeRoots returns candidate server roots, most specific first: the base URL +// without its version suffix (covers reverse proxies such as /ollama/v1), then +// the scheme+host origin. Duplicates are removed. +func probeRoots(parsed *url.URL) []string { + roots := make([]string, 0, 2) + add := func(root string) { + root = strings.TrimSuffix(strings.TrimSpace(root), "/") + if root == "" { + return + } + for _, existing := range roots { + if existing == root { + return + } + } + roots = append(roots, root) + } + path := strings.TrimSuffix(parsed.Path, "/") + path = strings.TrimSuffix(path, "/v1") + prefix := parsed.Scheme + "://" + parsed.Host + path + add(prefix) + add(parsed.Scheme + "://" + parsed.Host) + return roots +} + +func parseBaseURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = "https://api.openai.com/v1" + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil, err + } + return u, nil +} + +func detectOllama(ctx context.Context, client *http.Client, root, base, model, apiKey string) (ContextInfo, bool) { + var version struct { + Version string `json:"version"` + } + if status, err := probeJSON(ctx, client, http.MethodGet, root+"/api/version", apiKey, nil, &version); err != nil || status != http.StatusOK { + return ContextInfo{}, false + } + + info := ContextInfo{Backend: BackendOllama} + if strings.HasSuffix(strings.TrimRight(base, "/"), "/v1") { + info.Warnings = append(info.Warnings, WarningOllamaV1IgnoresNumCtx) + } + + if n, loaded := ollamaRuntimeContext(ctx, client, root, model, apiKey); n > 0 { + info.ContextWindow = n + info.Source = "ollama_ps" + info.Loaded = loaded + return info, true + } + + show := struct { + Parameters string `json:"parameters"` + ModelInfo map[string]json.RawMessage `json:"model_info"` + }{} + body := map[string]string{"model": model} + if status, err := probeJSON(ctx, client, http.MethodPost, root+"/api/show", apiKey, body, &show); err == nil && status == http.StatusOK { + if n := ollamaNumCtx(show.Parameters); n > 0 { + info.ContextWindow = n + info.Source = "ollama_show_num_ctx" + return info, true + } + if n := ollamaModelContext(show.ModelInfo); n > 0 { + info.ContextWindow = n + info.Source = "ollama_show_model_max" + info.RuntimeUnknown = true + info.Warnings = append(info.Warnings, WarningOllamaRuntimeUnknown) + return info, true + } + } + info.RuntimeUnknown = true + info.Warnings = append(info.Warnings, WarningOllamaRuntimeUnknown, WarningContextUnknown) + return info, true +} + +func ollamaRuntimeContext(ctx context.Context, client *http.Client, root, model, apiKey string) (int, bool) { + var ps struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + ContextLength int `json:"context_length"` + } `json:"models"` + } + if status, err := probeJSON(ctx, client, http.MethodGet, root+"/api/ps", apiKey, nil, &ps); err != nil || status != http.StatusOK { + return 0, false + } + best := 0 + for _, m := range ps.Models { + if m.ContextLength <= 0 || !modelMatches(m.Name, model) && !modelMatches(m.Model, model) { + continue + } + if m.ContextLength > best { + best = m.ContextLength + } + } + if best == 0 && len(ps.Models) == 1 && ps.Models[0].ContextLength > 0 && strings.TrimSpace(model) == "" { + best = ps.Models[0].ContextLength + } + return best, best > 0 +} + +func ollamaNumCtx(parameters string) int { + for _, line := range strings.Split(parameters, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || !strings.EqualFold(fields[0], "num_ctx") { + continue + } + if n, err := strconv.Atoi(fields[1]); err == nil && n > 0 { + return n + } + } + return 0 +} + +func ollamaModelContext(modelInfo map[string]json.RawMessage) int { + arch := "" + if raw, ok := modelInfo["general.architecture"]; ok { + _ = json.Unmarshal(raw, &arch) + } + if arch != "" { + if raw, ok := modelInfo[arch+".context_length"]; ok { + if n := rawInt(raw); n > 0 { + return n + } + } + } + best := 0 + for key, raw := range modelInfo { + if !strings.HasSuffix(key, ".context_length") { + continue + } + if n := rawInt(raw); n > best { + best = n + } + } + return best +} + +func detectLlamaCpp(ctx context.Context, client *http.Client, root, model, apiKey string) (ContextInfo, bool) { + var props struct { + NCtx int `json:"n_ctx"` + DefaultGenerationSettings struct { + NCtx int `json:"n_ctx"` + Params struct { + NCtx int `json:"n_ctx"` + } `json:"params"` + } `json:"default_generation_settings"` + TotalSlots int `json:"total_slots"` + } + status, err := probeJSON(ctx, client, http.MethodGet, root+"/props", apiKey, nil, &props) + if err != nil || status != http.StatusOK { + return ContextInfo{}, false + } + n := props.DefaultGenerationSettings.Params.NCtx + if n <= 0 { + n = props.DefaultGenerationSettings.NCtx + } + if n <= 0 { + n = props.NCtx + } + info := ContextInfo{Backend: BackendLlamaCpp, Loaded: true} + if n > 0 { + info.ContextWindow = n + info.Source = "llamacpp_props" + } else { + info.Warnings = append(info.Warnings, WarningContextUnknown) + } + return info, true +} + +type lmStudioModel struct { + ID string `json:"id"` + Type string `json:"type"` + State string `json:"state"` + MaxContextLength int `json:"max_context_length"` + LoadedContextLength int `json:"loaded_context_length"` + LoadedInstances []lmStudioInstance `json:"loaded_instances"` +} + +type lmStudioInstance struct { + ID string `json:"id"` + ContextLength int `json:"context_length"` + Config struct { + ContextLength int `json:"context_length"` + } `json:"config"` +} + +func detectLMStudio(ctx context.Context, client *http.Client, root, model, apiKey string) (ContextInfo, bool) { + var list struct { + Data []lmStudioModel `json:"data"` + Models []lmStudioModel `json:"models"` + } + paths := []string{"/api/v0/models", "/api/v1/models"} + for _, path := range paths { + status, err := probeJSON(ctx, client, http.MethodGet, root+path, apiKey, nil, &list) + if err != nil || status != http.StatusOK { + continue + } + models := list.Data + if len(models) == 0 { + models = list.Models + } + chosen := pickLMModel(models, model) + if chosen == nil { + return ContextInfo{Backend: BackendLMStudio, Warnings: []string{WarningContextUnknown}}, true + } + info := ContextInfo{Backend: BackendLMStudio} + if chosen.LoadedContextLength > 0 { + info.ContextWindow = chosen.LoadedContextLength + info.Source = "lmstudio_loaded_context" + info.Loaded = true + return info, true + } + for _, inst := range chosen.LoadedInstances { + if inst.ContextLength > 0 { + info.ContextWindow = inst.ContextLength + info.Source = "lmstudio_loaded_instance" + info.Loaded = true + return info, true + } + if inst.Config.ContextLength > 0 { + info.ContextWindow = inst.Config.ContextLength + info.Source = "lmstudio_loaded_instance_config" + info.Loaded = true + return info, true + } + } + if chosen.MaxContextLength > 0 { + info.ContextWindow = chosen.MaxContextLength + info.Source = "lmstudio_model_max" + info.RuntimeUnknown = true + info.Warnings = append(info.Warnings, WarningRuntimeUnknown) + return info, true + } + info.Warnings = append(info.Warnings, WarningContextUnknown) + return info, true + } + return ContextInfo{}, false +} + +func pickLMModel(models []lmStudioModel, wanted string) *lmStudioModel { + for i := range models { + if modelMatches(models[i].ID, wanted) { + return &models[i] + } + } + if len(models) == 1 { + return &models[0] + } + return nil +} + +func detectOpenAICompatible(ctx context.Context, client *http.Client, base, model, apiKey string) ContextInfo { + var list struct { + Data []struct { + ID string `json:"id"` + MaxModelLen int `json:"max_model_len"` + ContextLength json.RawMessage `json:"context_length"` + MaxContextLength int `json:"max_context_length"` + } `json:"data"` + } + status, err := probeJSON(ctx, client, http.MethodGet, strings.TrimRight(base, "/")+"/models", apiKey, nil, &list) + if err != nil || status != http.StatusOK { + return ContextInfo{Backend: BackendOpenAICompatible, Warnings: []string{WarningContextUnknown}} + } + info := ContextInfo{Backend: BackendOpenAICompatible} + chosen := -1 + for i := range list.Data { + if modelMatches(list.Data[i].ID, model) { + chosen = i + break + } + } + if chosen < 0 && len(list.Data) == 1 { + chosen = 0 + } + if chosen >= 0 { + m := list.Data[chosen] + switch { + case m.MaxModelLen > 0: + info.ContextWindow = m.MaxModelLen + info.Source = "openai_models_max_model_len" + case rawInt(m.ContextLength) > 0: + info.ContextWindow = rawInt(m.ContextLength) + info.Source = "openai_models_context_length" + case m.MaxContextLength > 0: + info.ContextWindow = m.MaxContextLength + info.Source = "openai_models_max_context_length" + } + if info.ContextWindow > 0 { + info.Loaded = true + return info + } + } + info.Warnings = append(info.Warnings, WarningContextUnknown) + return info +} + +func probeJSON(ctx context.Context, client *http.Client, method, target, apiKey string, body interface{}, out interface{}) (int, error) { + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, err + } + reader = strings.NewReader(string(raw)) + } + req, err := http.NewRequestWithContext(reqCtx, method, target, reader) + if err != nil { + return 0, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if strings.TrimSpace(apiKey) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKey)) + } + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return resp.StatusCode, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp.StatusCode, nil + } + if out == nil || len(raw) == 0 { + return resp.StatusCode, nil + } + if err := json.Unmarshal(raw, out); err != nil { + return resp.StatusCode, err + } + return resp.StatusCode, nil +} + +func rawInt(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + var n int + if err := json.Unmarshal(raw, &n); err == nil { + return n + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + n, _ = strconv.Atoi(strings.TrimSpace(s)) + } + return n +} + +func modelMatches(candidate, wanted string) bool { + c := strings.ToLower(strings.TrimSpace(candidate)) + w := strings.ToLower(strings.TrimSpace(wanted)) + if c == "" || w == "" { + return false + } + if c == w || strings.TrimSuffix(c, ":latest") == strings.TrimSuffix(w, ":latest") { + return true + } + return strings.HasSuffix(c, "/"+w) || strings.HasSuffix(w, "/"+c) +} diff --git a/internal/modelmeta/context_test.go b/internal/modelmeta/context_test.go new file mode 100644 index 000000000..ec71792fb --- /dev/null +++ b/internal/modelmeta/context_test.go @@ -0,0 +1,269 @@ +package modelmeta + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" +) + +func TestDetectContextOllamaLoadedRuntime(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"version": "0.12.0"}) + }) + mux.HandleFunc("/api/ps", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "models": []map[string]interface{}{ + {"name": "qwen3:8b", "model": "qwen3:8b", "context_length": 32768}, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "qwen3:8b", "", srv.Client()) + if info.Backend != BackendOllama || info.ContextWindow != 32768 || info.Source != "ollama_ps" || !info.Loaded { + t.Fatalf("unexpected info: %+v", info) + } + if !contains(info.Warnings, WarningOllamaV1IgnoresNumCtx) { + t.Fatalf("missing ollama v1 warning: %+v", info.Warnings) + } +} + +func TestDetectContextOllamaShowNumCtx(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"version": "0.12.0"}) + }) + mux.HandleFunc("/api/ps", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"models": []interface{}{}}) + }) + mux.HandleFunc("/api/show", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "parameters": "num_ctx 16384\nstop \"<|endoftext|>\"", + "model_info": map[string]interface{}{ + "general.architecture": "qwen3", + "qwen3.context_length": 131072, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "qwen3:8b", "", srv.Client()) + if info.ContextWindow != 16384 || info.Source != "ollama_show_num_ctx" || info.RuntimeUnknown { + t.Fatalf("unexpected info: %+v", info) + } +} + +func TestDetectContextOllamaShowModelMax(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"version": "0.12.0"}) + }) + mux.HandleFunc("/api/ps", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"models": []interface{}{}}) + }) + mux.HandleFunc("/api/show", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "parameters": "", + "model_info": map[string]interface{}{ + "general.architecture": "qwen3", + "qwen3.context_length": 131072, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "qwen3:8b", "", srv.Client()) + if info.ContextWindow != 131072 || info.Source != "ollama_show_model_max" || !info.RuntimeUnknown { + t.Fatalf("unexpected info: %+v", info) + } + if !contains(info.Warnings, WarningOllamaRuntimeUnknown) { + t.Fatalf("missing runtime-unknown warning: %+v", info.Warnings) + } +} + +func TestDetectContextLlamaCpp(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "default_generation_settings": map[string]interface{}{ + "n_ctx": 8192, + "params": map[string]interface{}{"n_ctx": 8192}, + }, + "total_slots": 1, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-model", "", srv.Client()) + if info.Backend != BackendLlamaCpp || info.ContextWindow != 8192 || info.Source != "llamacpp_props" || !info.Loaded { + t.Fatalf("unexpected info: %+v", info) + } +} + +func TestDetectContextLMStudioLoadedInstance(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/api/v0/models", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + { + "id": "qwen/qwen3-8b", + "type": "llm", + "state": "loaded", + "max_context_length": 131072, + "loaded_instances": []map[string]interface{}{ + {"id": "qwen/qwen3-8b", "config": map[string]interface{}{"context_length": 16384}}, + }, + }, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "qwen/qwen3-8b", "", srv.Client()) + if info.Backend != BackendLMStudio || info.ContextWindow != 16384 || info.Source != "lmstudio_loaded_instance_config" || !info.Loaded { + t.Fatalf("unexpected info: %+v", info) + } +} + +func TestDetectContextVLLMModels(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "Qwen/Qwen3-8B", "max_model_len": 65536}, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "Qwen/Qwen3-8B", "", srv.Client()) + if info.Backend != BackendOpenAICompatible || info.ContextWindow != 65536 || info.Source != "openai_models_max_model_len" { + t.Fatalf("unexpected info: %+v", info) + } +} + +func TestDetectContextOpenAISingleModelFallback(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "Qwen/Qwen3-8B", "max_model_len": 65536}, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-alias", "", srv.Client()) + if info.ContextWindow != 65536 { + t.Fatalf("single-model fallback failed: %+v", info) + } +} + +func TestDetectContextUnknown(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "x", "", srv.Client()) + if info.ContextWindow != 0 || !contains(info.Warnings, WarningContextUnknown) { + t.Fatalf("unexpected info: %+v", info) + } +} + +func TestDetectContextOllamaBehindPrefix(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/ollama/api/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"version": "0.12.0"}) + }) + mux.HandleFunc("/ollama/api/ps", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "models": []map[string]interface{}{ + {"name": "qwen3:8b", "context_length": 65536}, + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/ollama/v1", "qwen3:8b", "", srv.Client()) + if info.Backend != BackendOllama || info.ContextWindow != 65536 || info.Source != "ollama_ps" { + t.Fatalf("unexpected info behind prefix: %+v", info) + } +} + +func TestDetectContextLlamaCppBehindPrefix(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/llama/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"n_ctx": 16384}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/llama/v1", "local-model", "", srv.Client()) + if info.Backend != BackendLlamaCpp || info.ContextWindow != 16384 { + t.Fatalf("unexpected info behind prefix: %+v", info) + } +} + +func TestProbeRootsPrefersPrefix(t *testing.T) { + t.Parallel() + parsed, err := url.Parse("https://example.com/ollama/v1/") + if err != nil { + t.Fatal(err) + } + got := probeRoots(parsed) + want := []string{"https://example.com/ollama", "https://example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("probeRoots() = %#v, want %#v", got, want) + } +} + +func TestModelMatches(t *testing.T) { + t.Parallel() + cases := []struct { + candidate string + wanted string + want bool + }{ + {"qwen3:8b", "qwen3:8b", true}, + {"qwen3:latest", "qwen3", true}, + {"qwen/qwen3-8b", "qwen3-8b", true}, + {"Qwen/Qwen3-8B", "qwen/qwen3-8b", true}, + {"other", "qwen3", false}, + } + for _, tc := range cases { + if got := modelMatches(tc.candidate, tc.wanted); got != tc.want { + t.Fatalf("modelMatches(%q,%q) = %v, want %v", tc.candidate, tc.wanted, got, tc.want) + } + } +} + +func contains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/internal/multiagent/context_budget.go b/internal/multiagent/context_budget.go index ba93e3a98..96088a7e9 100644 --- a/internal/multiagent/context_budget.go +++ b/internal/multiagent/context_budget.go @@ -4,14 +4,40 @@ import ( "context" "fmt" "strings" + "sync" "unicode/utf8" + "cyberstrike-ai/internal/config" + "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk/middlewares/summarization" "github.com/cloudwego/eino/schema" "go.uber.org/zap" ) +var contextBudgetWarned sync.Map + +func logContextBudgetWarnings(oa config.OpenAIConfig, logger *zap.Logger, phase string) { + if logger == nil { + return + } + warnings := oa.ContextBudgetWarnings() + if len(warnings) == 0 { + return + } + key := fmt.Sprintf("%d|%d|%s", oa.EffectiveContextWindow(), oa.MaxTotalTokens, strings.Join(warnings, ",")) + if _, loaded := contextBudgetWarned.LoadOrStore(key, struct{}{}); loaded { + return + } + logger.Warn("model context budget adjusted", + zap.String("phase", phase), + zap.Int("context_window", oa.EffectiveContextWindow()), + zap.Int("configured_max_total_tokens", oa.MaxTotalTokens), + zap.Int("effective_max_total_tokens", oa.EffectiveMaxTotalTokens()), + zap.Strings("warnings", warnings), + ) +} + const ( toolOutputTruncationMarker = "\n\n...[tool output truncated; full text persisted in reduction cache or summarization transcript]...\n\n" aggressiveToolTruncDivisor = 4 @@ -37,9 +63,16 @@ func isEinoContextOverflowError(err error) bool { "token limit", "tokens exceed", "exceeds the context", + "exceeds the available context", // llama.cpp: request exceeds the available context size + "available context size", "input is too long", + "input length exceeds", "prompt is too long", + "prompt too long", + "max_model_len", // vLLM: input tokens exceed max_model_len + "maximum model length", "request too large", + "tokens to keep", // LM Studio truncation warning } for _, m := range markers { if strings.Contains(msg, m) { @@ -174,6 +207,7 @@ func truncateRoundMessagesToTokenBudget( type compactMessagesOpts struct { maxTokens int counter summarization.TokenCounterFunc + tools []*schema.ToolInfo toolMaxBytes int spillRef string aggressive bool @@ -181,6 +215,28 @@ type compactMessagesOpts struct { phase string } +func countToolTokens(ctx context.Context, counter summarization.TokenCounterFunc, tools []*schema.ToolInfo) int { + if counter == nil || len(tools) == 0 { + return 0 + } + n, err := countMessagesTokens(ctx, nil, counter, tools) + if err != nil { + return 0 + } + return n +} + +func countMessagesTokensNoTools(ctx context.Context, msgs []adk.Message, counter summarization.TokenCounterFunc) int { + if counter == nil { + return 0 + } + n, err := countMessagesTokens(ctx, msgs, counter, nil) + if err != nil { + return 0 + } + return n +} + func compactMessagesByDroppingRounds( ctx context.Context, messages []adk.Message, @@ -189,10 +245,23 @@ func compactMessagesByDroppingRounds( if opts.maxTokens <= 0 || len(messages) == 0 || opts.counter == nil { return messages, false } - before, err := countMessagesTokens(ctx, messages, opts.counter, nil) + before, err := countMessagesTokens(ctx, messages, opts.counter, opts.tools) if err != nil || before <= opts.maxTokens { return messages, false } + toolTokens := countToolTokens(ctx, opts.counter, opts.tools) + if toolTokens >= opts.maxTokens { + if opts.logger != nil { + opts.logger.Warn("model input budget exhausted by tool schemas", + zap.String("phase", opts.phase), + zap.Int("tool_tokens_estimated", toolTokens), + zap.Int("message_tokens_estimated", countMessagesTokensNoTools(ctx, messages, opts.counter)), + zap.Int("max_tokens", opts.maxTokens), + zap.Int("tools", len(opts.tools)), + ) + } + return messages, false + } systems := make([]adk.Message, 0, 1) contextMsgs := make([]adk.Message, 0, len(messages)) @@ -232,7 +301,7 @@ func compactMessagesByDroppingRounds( for _, round := range rounds { candidate = append(candidate, round.messages...) } - after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil) + after, countErr := countMessagesTokens(ctx, candidate, opts.counter, opts.tools) if countErr != nil { break } @@ -243,6 +312,7 @@ func compactMessagesByDroppingRounds( zap.Int("tokens_before", before), zap.Int("tokens_after", after), zap.Int("max_tokens", opts.maxTokens), + zap.Int("tool_tokens_estimated", toolTokens), zap.Int("dropped_rounds", dropped), zap.Bool("aggressive", opts.aggressive), ) @@ -258,8 +328,12 @@ func compactMessagesByDroppingRounds( return messages, false } latest := rounds[len(rounds)-1] + messageBudget := opts.maxTokens - toolTokens + if messageBudget < 1024 { + messageBudget = 1024 + } truncated, truncErr := truncateRoundMessagesToTokenBudget( - ctx, latest, opts.maxTokens, opts.counter, opts.toolMaxBytes, opts.spillRef, + ctx, latest, messageBudget, opts.counter, opts.toolMaxBytes, opts.spillRef, ) if truncErr != nil || len(truncated) == 0 { if opts.logger != nil { @@ -267,6 +341,7 @@ func compactMessagesByDroppingRounds( zap.String("phase", opts.phase), zap.Int("tokens_before", before), zap.Int("max_tokens", opts.maxTokens), + zap.Int("tool_tokens_estimated", toolTokens), zap.Bool("aggressive", opts.aggressive), ) } @@ -279,7 +354,7 @@ func compactMessagesByDroppingRounds( } } candidate = append(candidate, truncated...) - after, countErr := countMessagesTokens(ctx, candidate, opts.counter, nil) + after, countErr := countMessagesTokens(ctx, candidate, opts.counter, opts.tools) if countErr != nil { return messages, false } @@ -289,6 +364,7 @@ func compactMessagesByDroppingRounds( zap.Int("tokens_before", before), zap.Int("tokens_after", after), zap.Int("max_tokens", opts.maxTokens), + zap.Int("tool_tokens_estimated", toolTokens), zap.Int("dropped_rounds", dropped), zap.Bool("aggressive", opts.aggressive), ) @@ -301,6 +377,7 @@ func aggressiveCompactMessagesForOverflow( messages []adk.Message, maxTotalTokens int, modelName string, + tokenSafetyPercent int, toolMaxBytes int, phase string, logger *zap.Logger, @@ -318,7 +395,7 @@ func aggressiveCompactMessagesForOverflow( } out, _ := compactMessagesByDroppingRounds(ctx, messages, compactMessagesOpts{ maxTokens: budget, - counter: einoSummarizationTokenCounter(modelName), + counter: einoSummarizationTokenCounterWithSafety(modelName, tokenSafetyPercent), toolMaxBytes: aggressiveToolMax, aggressive: true, logger: logger, diff --git a/internal/multiagent/context_budget_test.go b/internal/multiagent/context_budget_test.go index a9b0ece8f..7e6575926 100644 --- a/internal/multiagent/context_budget_test.go +++ b/internal/multiagent/context_budget_test.go @@ -6,7 +6,10 @@ import ( "strings" "testing" + "cyberstrike-ai/internal/config" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/middlewares/summarization" "github.com/cloudwego/eino/schema" ) @@ -20,6 +23,10 @@ func TestIsEinoContextOverflowError(t *testing.T) { {errors.New("context length exceeded"), true}, {errors.New("maximum context length"), true}, {errors.New("input is too long for model"), true}, + {errors.New("the request exceeds the available context size, try increasing it"), true}, + {errors.New("input length exceeds the context length"), true}, + {errors.New("prompt too long"), true}, + {errors.New("The input length of the prompt is 9000, exceeding max_model_len 8192"), true}, {errors.New("HTTP 429 Too Many Requests"), false}, {errors.New("invalid api key"), false}, } @@ -82,6 +89,96 @@ func TestBuildBudgetedSummarizationModelInputTruncatesOversizedLatestRound(t *te } } +func TestLocalTokenCounterSafetyMargin(t *testing.T) { + t.Parallel() + input := &summarization.TokenCounterInput{Messages: []adk.Message{ + schema.UserMessage(strings.Repeat("目标主机存在漏洞,需要进一步验证。", 40)), + }} + baseline := einoSummarizationTokenCounterWithSafety("qwen3-8b", 100) + local := einoSummarizationTokenCounterWithSafety("qwen3-8b", config.LocalTokenEstimateSafetyPercent) + baseCount, err := baseline(context.Background(), input) + if err != nil { + t.Fatal(err) + } + localCount, err := local(context.Background(), input) + if err != nil { + t.Fatal(err) + } + want := (baseCount*config.LocalTokenEstimateSafetyPercent + 99) / 100 + if localCount != want { + t.Fatalf("local count = %d, want %d (baseline %d)", localCount, want, baseCount) + } + if localCount <= baseCount { + t.Fatalf("local count %d must exceed baseline %d", localCount, baseCount) + } +} + +func TestCompactMessagesCountsToolSchemas(t *testing.T) { + t.Parallel() + counter := func(_ context.Context, in *summarization.TokenCounterInput) (int, error) { + n := 100 * len(in.Tools) + for _, msg := range in.Messages { + if msg != nil { + n += len(msg.Content) + } + } + return n, nil + } + msgs := []adk.Message{ + schema.UserMessage(strings.Repeat("a", 25)), + schema.AssistantMessage(strings.Repeat("b", 25), nil), + schema.UserMessage(strings.Repeat("c", 25)), + schema.AssistantMessage(strings.Repeat("d", 25), nil), + } + out, changed := compactMessagesByDroppingRounds(context.Background(), msgs, compactMessagesOpts{ + maxTokens: 150, + counter: counter, + tools: []*schema.ToolInfo{{Name: "t"}}, + phase: "test", + }) + if !changed { + t.Fatal("expected compaction once tool schema tokens are included") + } + total := 100 + for _, msg := range out { + if msg != nil { + total += len(msg.Content) + } + } + if total > 150 { + t.Fatalf("compacted input = %d tokens, want <= 150", total) + } +} + +func TestCompactMessagesLeavesInputWhenToolsExceedBudget(t *testing.T) { + t.Parallel() + counter := func(_ context.Context, in *summarization.TokenCounterInput) (int, error) { + n := 100 * len(in.Tools) + for _, msg := range in.Messages { + if msg != nil { + n += len(msg.Content) + } + } + return n, nil + } + msgs := []adk.Message{ + schema.UserMessage(strings.Repeat("a", 25)), + schema.AssistantMessage(strings.Repeat("b", 25), nil), + } + out, changed := compactMessagesByDroppingRounds(context.Background(), msgs, compactMessagesOpts{ + maxTokens: 50, + counter: counter, + tools: []*schema.ToolInfo{{Name: "t"}}, + phase: "test", + }) + if changed { + t.Fatal("message compaction cannot fix a tool-schema budget overrun") + } + if len(out) != len(msgs) { + t.Fatalf("messages changed: %d -> %d", len(msgs), len(out)) + } +} + func TestModelInputSoftBudgetNeverErrors(t *testing.T) { mw := &modelInputSoftBudgetMiddleware{ maxTokens: 4, diff --git a/internal/multiagent/eino_adk_run_loop.go b/internal/multiagent/eino_adk_run_loop.go index 3f06ec786..197da7318 100644 --- a/internal/multiagent/eino_adk_run_loop.go +++ b/internal/multiagent/eino_adk_run_loop.go @@ -145,10 +145,12 @@ type einoADKRunLoopArgs struct { EinoCallbacks *config.MultiAgentEinoCallbacksConfig // MaxTotalTokens / ToolMaxBytes / ModelName 用于 context overflow 时的激进压缩续跑。 - MaxTotalTokens int - ToolMaxBytes int - ModelName string - MiddlewareConfig *config.MultiAgentEinoMiddlewareConfig + MaxTotalTokens int + ToolMaxBytes int + ModelName string + // TokenSafetyPercent 是 local 档位对 token 估算的保守放大比例(auto 为 100)。 + TokenSafetyPercent int + MiddlewareConfig *config.MultiAgentEinoMiddlewareConfig // TurnLoopInterruptTimeout 仅供测试/特殊运行时覆盖;0 使用 EinoTurnLoopRuntime 默认值。 TurnLoopInterruptTimeout time.Duration diff --git a/internal/multiagent/eino_agentic_summarize.go b/internal/multiagent/eino_agentic_summarize.go index 10bd561bc..0e1122b67 100644 --- a/internal/multiagent/eino_agentic_summarize.go +++ b/internal/multiagent/eino_agentic_summarize.go @@ -66,7 +66,7 @@ func newEinoAgenticSummarizationMiddleware( if modelName == "" { modelName = "gpt-4o" } - classicTokenCounter := einoSummarizationTokenCounter(modelName) + classicTokenCounter := einoSummarizationTokenCounterForChannel(modelName, appCfg.OpenAI) agenticTokenCounter := func(ctx context.Context, input *summarization.TypedTokenCounterInput[*schema.AgenticMessage]) (int, error) { if input == nil { return 0, nil diff --git a/internal/multiagent/eino_chat_model_tail_middleware.go b/internal/multiagent/eino_chat_model_tail_middleware.go index 66352887d..391138a23 100644 --- a/internal/multiagent/eino_chat_model_tail_middleware.go +++ b/internal/multiagent/eino_chat_model_tail_middleware.go @@ -31,6 +31,7 @@ type einoChatModelTailConfig struct { agenticSummarization adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] modelName string maxTotalTokens int + tokenSafetyPercent int toolMaxBytes int conversationID string trace *modelFacingTraceHolder @@ -50,7 +51,7 @@ func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware, handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization")) handlers = append(handlers, cfg.summarization) } - handlers = append(handlers, newModelInputSoftBudgetMiddleware(cfg.maxTotalTokens, cfg.toolMaxBytes, cfg.modelName, cfg.logger, cfg.phase)) + handlers = append(handlers, newModelInputSoftBudgetMiddleware(cfg.maxTotalTokens, cfg.toolMaxBytes, cfg.modelName, cfg.tokenSafetyPercent, cfg.logger, cfg.phase)) handlers = append(handlers, newToolCallArgumentsSanitizerMiddleware(cfg.logger, cfg.phase)) handlers = append(handlers, newToolPairReconcilerMiddleware(cfg.logger, cfg.phase)) if !cfg.skipOrphanPruner { diff --git a/internal/multiagent/eino_context_overflow_retry.go b/internal/multiagent/eino_context_overflow_retry.go index b242b4401..ba7414052 100644 --- a/internal/multiagent/eino_context_overflow_retry.go +++ b/internal/multiagent/eino_context_overflow_retry.go @@ -53,6 +53,7 @@ func (h *einoContextOverflowRetryHandler) Prepare( restartMsgs, h.cfg.Args.MaxTotalTokens, h.cfg.Args.ModelName, + h.cfg.Args.TokenSafetyPercent, h.cfg.Args.ToolMaxBytes, h.cfg.OrchMode, h.cfg.Logger, diff --git a/internal/multiagent/eino_middleware.go b/internal/multiagent/eino_middleware.go index 1f2c057a2..434c21865 100644 --- a/internal/multiagent/eino_middleware.go +++ b/internal/multiagent/eino_middleware.go @@ -80,7 +80,7 @@ func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbac return static, dynamic, true } -func mergeAlwaysVisibleToolNames(configured []string) []string { +func mergeAlwaysVisibleToolNames(configured []string, includeBuiltins bool) []string { merged := make([]string, 0, len(configured)+32) seen := make(map[string]struct{}, len(configured)+32) add := func(name string) { @@ -97,9 +97,11 @@ func mergeAlwaysVisibleToolNames(configured []string) []string { for _, n := range configured { add(n) } - // Always include hardcoded backend builtin MCP tools from constants. - for _, n := range builtin.GetAllBuiltinTools() { - add(n) + if includeBuiltins { + // Always include hardcoded backend builtin MCP tools from constants. + for _, n := range builtin.GetAllBuiltinTools() { + add(n) + } } return merged } @@ -235,7 +237,7 @@ func prependEinoMiddlewares( alwaysVis = 12 } if mw.ToolSearchEnable && len(tools) >= minTools { - static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools), alwaysVis) + static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools, mw.ToolSearchIncludeBuiltinsEffective()), alwaysVis) if split && len(dynamic) > 0 { ts, terr := toolsearch.New(ctx, &toolsearch.Config{DynamicTools: dynamic}) if terr != nil { @@ -326,7 +328,7 @@ func prependEinoAgenticMiddlewares( alwaysVis = 12 } if mw.ToolSearchEnable && len(tools) >= minTools { - static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools), alwaysVis) + static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools, mw.ToolSearchIncludeBuiltinsEffective()), alwaysVis) if split && len(dynamic) > 0 { ts, terr := toolsearch.NewTyped[*schema.AgenticMessage](ctx, &toolsearch.Config{DynamicTools: dynamic}) if terr != nil { diff --git a/internal/multiagent/eino_middleware_test.go b/internal/multiagent/eino_middleware_test.go index 45842d86c..8d7e94359 100644 --- a/internal/multiagent/eino_middleware_test.go +++ b/internal/multiagent/eino_middleware_test.go @@ -15,6 +15,18 @@ import ( "github.com/cloudwego/eino/schema" ) +func TestMergeAlwaysVisibleToolNamesCanExcludeBuiltins(t *testing.T) { + t.Parallel() + coreOnly := mergeAlwaysVisibleToolNames([]string{"read_file"}, false) + if len(coreOnly) != 1 || coreOnly[0] != "read_file" { + t.Fatalf("core only = %#v, want [read_file]", coreOnly) + } + withBuiltins := mergeAlwaysVisibleToolNames([]string{"read_file"}, true) + if len(withBuiltins) <= len(coreOnly) { + t.Fatalf("builtins were not merged: %#v", withBuiltins) + } +} + func TestReductionCacheRootDir(t *testing.T) { got := reductionCacheRootDir("", "proj-1", "conv-1") want := filepath.Join("tmp", "reduction", "projects", "proj-1") diff --git a/internal/multiagent/eino_model_resilience.go b/internal/multiagent/eino_model_resilience.go index 4725a3390..3a2abf3df 100644 --- a/internal/multiagent/eino_model_resilience.go +++ b/internal/multiagent/eino_model_resilience.go @@ -70,11 +70,16 @@ func newEinoToolCallingChatModelFactory( openai.AttachSummarizationDiagTransport(httpClient, logger) maxCompletionTokens := oa.MaxCompletionTokensEffective() modelCfg := &einoopenai.ChatModelConfig{ - APIKey: oa.APIKey, - BaseURL: strings.TrimSuffix(oa.BaseURL, "/"), - Model: oa.Model, - HTTPClient: httpClient, - MaxCompletionTokens: &maxCompletionTokens, + APIKey: oa.APIKey, + BaseURL: strings.TrimSuffix(oa.BaseURL, "/"), + Model: oa.Model, + HTTPClient: httpClient, + } + if oa.IsLocalContextProfile() { + // Ollama/llama.cpp 等本地端点普遍只认旧字段 max_tokens。 + modelCfg.MaxTokens = &maxCompletionTokens + } else { + modelCfg.MaxCompletionTokens = &maxCompletionTokens } if mode == einoModelModePlanner { reasoning.ApplyPlanExecutePlannerModelConfig(modelCfg, &oa) @@ -107,16 +112,24 @@ func newEinoAgenticChatModelFactory( httpClient := openai.NewEinoHTTPClient(&oa, baseHTTPClient) openai.AttachSummarizationDiagTransport(httpClient, logger) maxCompletionTokens := oa.MaxCompletionTokensEffective() + fields := reasoning.AgenticOpenAIExtraFields(&oa, reasoningClient) + if mode == einoModelModePlanner { + fields = reasoning.AgenticOpenAIPlannerExtraFields(&oa) + } modelCfg := &agenticopenai.ChatConfig{ - APIKey: oa.APIKey, - BaseURL: strings.TrimSuffix(oa.BaseURL, "/"), - Model: oa.Model, - HTTPClient: httpClient, - MaxCompletionTokens: &maxCompletionTokens, - ExtraFields: reasoning.AgenticOpenAIExtraFields(&oa, reasoningClient), + APIKey: oa.APIKey, + BaseURL: strings.TrimSuffix(oa.BaseURL, "/"), + Model: oa.Model, + HTTPClient: httpClient, + ExtraFields: fields, } - if mode == einoModelModePlanner { - modelCfg.ExtraFields = reasoning.AgenticOpenAIPlannerExtraFields(&oa) + if oa.IsLocalContextProfile() { + if modelCfg.ExtraFields == nil { + modelCfg.ExtraFields = make(map[string]any) + } + modelCfg.ExtraFields["max_tokens"] = maxCompletionTokens + } else { + modelCfg.MaxCompletionTokens = &maxCompletionTokens } return agenticopenai.NewChatModel(ctx, modelCfg) } diff --git a/internal/multiagent/eino_model_resilience_test.go b/internal/multiagent/eino_model_resilience_test.go index 8f7f51089..868de5380 100644 --- a/internal/multiagent/eino_model_resilience_test.go +++ b/internal/multiagent/eino_model_resilience_test.go @@ -2,7 +2,10 @@ package multiagent import ( "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "testing" "time" @@ -385,6 +388,82 @@ func TestNewEinoToolCallingChatModelFactoryUsesNativeClaudeAdapter(t *testing.T) } } +func TestLocalContextProfileUsesMaxTokensForBothModelFactories(t *testing.T) { + t.Parallel() + cases := []struct { + name string + agentic bool + }{ + {name: "classic"}, + {name: "agentic", agentic: true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + bodyCh := make(chan map[string]any, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + bodyCh <- body + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "test", + "object": "chat.completion", + "created": 1, + "model": "qwen3-8b", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }}, + }) + })) + defer srv.Close() + + oa := config.OpenAIConfig{ + Provider: "openai_compatible", + APIKey: "test-key", + BaseURL: srv.URL, + Model: "qwen3-8b", + ContextWindow: 32768, + ContextProfile: "local", + MaxCompletionTokens: 32768, + } + ctx := context.Background() + if tc.agentic { + m, err := newEinoAgenticChatModelFactory(srv.Client(), nil, nil)(ctx, oa, einoModelModeNormal) + if err != nil { + t.Fatal(err) + } + if _, err := m.Generate(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("hi")}); err != nil { + t.Fatal(err) + } + } else { + m, err := newEinoToolCallingChatModelFactory(srv.Client(), nil, nil)(ctx, oa, einoModelModeNormal) + if err != nil { + t.Fatal(err) + } + if _, err := m.Generate(ctx, []*schema.Message{schema.UserMessage("hi")}); err != nil { + t.Fatal(err) + } + } + + body := <-bodyCh + if _, ok := body["max_completion_tokens"]; ok { + t.Fatalf("local request contained max_completion_tokens: %#v", body) + } + if got, ok := body["max_tokens"].(float64); !ok || int(got) != 16384 { + t.Fatalf("max_tokens = %#v, want 16384", body["max_tokens"]) + } + }) + } +} + func TestEinoNativeRetryErrorsDoNotTriggerRunLevelTransientRetry(t *testing.T) { t.Parallel() err := &adk.WillRetryError{ErrStr: "HTTP 429 Too Many Requests", RetryAttempt: 1} diff --git a/internal/multiagent/eino_orchestration.go b/internal/multiagent/eino_orchestration.go index 854cff3ea..7169c611f 100644 --- a/internal/multiagent/eino_orchestration.go +++ b/internal/multiagent/eino_orchestration.go @@ -128,6 +128,7 @@ func buildPlanExecuteAgenticExecutorHandlers(ctx context.Context, a *PlanExecute agenticSummarization: sumMw, modelName: a.ModelName, maxTotalTokens: a.AppCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: a.AppCfg.OpenAI.TokenEstimateSafetyPercent(), toolMaxBytes: toolMaxBytesFromMW(a.MwCfg), conversationID: a.ConversationID, trace: a.ModelFacingTrace, diff --git a/internal/multiagent/eino_single_runner.go b/internal/multiagent/eino_single_runner.go index eb3eb445b..d3f545ba8 100644 --- a/internal/multiagent/eino_single_runner.go +++ b/internal/multiagent/eino_single_runner.go @@ -45,6 +45,14 @@ func RunEinoSingleChatModelAgent( if ma == nil { return nil, fmt.Errorf("eino single: multi_agent 配置为空") } + appCfg = appCfg.WithEffectiveContextBudget() + { + maCopy := *ma + maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware + ma = &maCopy + } + logContextBudgetWarnings(appCfg.OpenAI, logger, "eino_single") + runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) einoLoc, einoSkillMW, einoFSTools, skillsRoot, einoErr := prepareEinoAgenticSkills(ctx, appCfg.SkillsDir, ma, logger) @@ -134,6 +142,7 @@ func RunEinoSingleChatModelAgent( agenticSummarization: mainSumMw, modelName: appCfg.OpenAI.Model, maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), conversationID: conversationID, trace: modelFacingTrace, @@ -227,6 +236,7 @@ func RunEinoSingleChatModelAgent( ModelFacingTrace: modelFacingTrace, EinoCallbacks: &ma.EinoCallbacks, MaxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + TokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), ToolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), ModelName: appCfg.OpenAI.Model, MiddlewareConfig: &ma.EinoMiddleware, diff --git a/internal/multiagent/eino_summarize.go b/internal/multiagent/eino_summarize.go index 8dc0aea62..01e693dff 100644 --- a/internal/multiagent/eino_summarize.go +++ b/internal/multiagent/eino_summarize.go @@ -135,7 +135,7 @@ func newEinoSummarizationMiddleware( if modelName == "" { modelName = "gpt-4o" } - tokenCounter := einoSummarizationTokenCounter(modelName) + tokenCounter := einoSummarizationTokenCounterForChannel(modelName, appCfg.OpenAI) recentTrailMax := trigger / 4 if recentTrailMax < 2048 { recentTrailMax = 2048 @@ -314,9 +314,12 @@ func newEinoSummarizationModelOptions(outputReserve int, modelName, kind string, label = "eino " + kind + " summarization generate request" } opts := make([]model.Option, 0, 4) - if oa != nil && isEinoAgenticClaudeProvider(oa.Provider) { + switch { + case oa != nil && isEinoAgenticClaudeProvider(oa.Provider): opts = append(opts, model.WithMaxTokens(outputReserve)) - } else { + case oa != nil && oa.IsLocalContextProfile(): + opts = append(opts, einoopenai.WithExtraFields(map[string]any{"max_tokens": outputReserve})) + default: opts = append(opts, einoopenai.WithMaxCompletionTokens(outputReserve)) } opts = append(opts, @@ -700,6 +703,16 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error { } func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc { + return einoSummarizationTokenCounterWithSafety(openAIModel, 100) +} + +// einoSummarizationTokenCounterForChannel applies the channel's conservative +// estimate margin so local tokenizers do not silently overflow the runtime window. +func einoSummarizationTokenCounterForChannel(openAIModel string, oa config.OpenAIConfig) summarization.TokenCounterFunc { + return einoSummarizationTokenCounterWithSafety(openAIModel, oa.TokenEstimateSafetyPercent()) +} + +func einoSummarizationTokenCounterWithSafety(openAIModel string, safetyPercent int) summarization.TokenCounterFunc { tc := agent.NewTikTokenCounter() return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) { var sb strings.Builder @@ -744,7 +757,10 @@ func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounte text := sb.String() n, err := tc.Count(openAIModel, text) if err != nil { - return (len(text) + 3) / 4, nil + n = (len(text) + 3) / 4 + } + if safetyPercent > 100 { + n = (n*safetyPercent + 99) / 100 } return n, nil } diff --git a/internal/multiagent/eino_summarize_payload_test.go b/internal/multiagent/eino_summarize_payload_test.go index 21e7e17ca..95a393eb9 100644 --- a/internal/multiagent/eino_summarize_payload_test.go +++ b/internal/multiagent/eino_summarize_payload_test.go @@ -151,6 +151,48 @@ func TestEinoSummarizationModelOptionsSetOnlyMaxCompletionTokens(t *testing.T) { } } +func TestEinoSummarizationModelOptionsLocalUsesMaxTokens(t *testing.T) { + const outputReserve = 4096 + oa := &config.OpenAIConfig{ContextProfile: "local", ContextWindow: 131072} + opts := newEinoSummarizationModelOptions(outputReserve, "qwen3-8b", "agentic", oa, nil) + + bodyCh := make(chan map[string]any, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + bodyCh <- body + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte("data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"summary\"},\"finish_reason\":null}]}\n\n")) + w.Write([]byte("data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")) + w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + chatModel, err := einoopenai.NewChatModel(context.Background(), &einoopenai.ChatModelConfig{ + APIKey: "test-key", + BaseURL: server.URL, + Model: "qwen3-8b", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := newNonEmptySummaryChatModel(chatModel).Generate(context.Background(), []*schema.Message{schema.UserMessage("summarize")}, opts...); err != nil { + t.Fatal(err) + } + body := <-bodyCh + if _, ok := body["max_completion_tokens"]; ok { + t.Fatalf("local request contained max_completion_tokens: %#v", body) + } + if got, ok := body["max_tokens"].(float64); !ok || int(got) != outputReserve { + t.Fatalf("max_tokens = %#v, want %d", body["max_tokens"], outputReserve) + } +} + // Exercise both production factories and their real SDK options, not just a // fake model: a truncated summary must be replaced only by a complete retry. func TestSummaryOutputLimitConciseRetry(t *testing.T) { diff --git a/internal/multiagent/model_input_soft_budget_middleware.go b/internal/multiagent/model_input_soft_budget_middleware.go index b01efd9f9..c0a48ed36 100644 --- a/internal/multiagent/model_input_soft_budget_middleware.go +++ b/internal/multiagent/model_input_soft_budget_middleware.go @@ -24,6 +24,7 @@ func newModelInputSoftBudgetMiddleware( maxTotalTokens int, toolMaxBytes int, modelName string, + tokenSafetyPercent int, logger *zap.Logger, phase string, ) adk.ChatModelAgentMiddleware { @@ -36,7 +37,7 @@ func newModelInputSoftBudgetMiddleware( return &modelInputSoftBudgetMiddleware{ maxTokens: maxTotalTokens, toolMaxBytes: toolMaxBytes, - counter: einoSummarizationTokenCounter(modelName), + counter: einoSummarizationTokenCounterWithSafety(modelName, tokenSafetyPercent), logger: logger, phase: phase, } @@ -53,6 +54,7 @@ func (m *modelInputSoftBudgetMiddleware) BeforeModelRewriteState( compacted, changed := compactMessagesByDroppingRounds(ctx, state.Messages, compactMessagesOpts{ maxTokens: m.maxTokens, counter: m.counter, + tools: state.ToolInfos, toolMaxBytes: m.toolMaxBytes, phase: m.phase, logger: m.logger, diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index 6b2e1e4f1..47924da26 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -84,6 +84,14 @@ func RunDeepAgent( return nil, fmt.Errorf("multiagent: 配置或 Agent 为空") } + appCfg = appCfg.WithEffectiveContextBudget() + if ma != nil { + maCopy := *ma + maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware + ma = &maCopy + } + logContextBudgetWarnings(appCfg.OpenAI, logger, "deep") + runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) effectiveSubs := ma.SubAgents @@ -247,6 +255,7 @@ func RunDeepAgent( agenticSummarization: subSumMw, modelName: appCfg.OpenAI.Model, maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), conversationID: conversationID, middlewareConfig: &ma.EinoMiddleware, @@ -426,6 +435,7 @@ func RunDeepAgent( agenticSummarization: mainSumMw, modelName: appCfg.OpenAI.Model, maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), conversationID: conversationID, trace: modelFacingTrace, @@ -445,6 +455,7 @@ func RunDeepAgent( agenticSummarization: mainSumMw, modelName: appCfg.OpenAI.Model, maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), conversationID: conversationID, trace: modelFacingTrace, @@ -519,15 +530,16 @@ func RunDeepAgent( AgenticFilesystemMiddleware: peFsMw, ModelFacingTrace: modelFacingTrace, PlannerReplannerRewriteHandlers: appendEinoChatModelTailMiddlewares(nil, einoChatModelTailConfig{ - logger: logger, - phase: "plan_execute_planner_replanner", - summarization: planRewriteSumMw, - modelName: appCfg.OpenAI.Model, - maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, - toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), - conversationID: conversationID, - skipTrace: true, - middlewareConfig: &ma.EinoMiddleware, + logger: logger, + phase: "plan_execute_planner_replanner", + summarization: planRewriteSumMw, + modelName: appCfg.OpenAI.Model, + maxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + tokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), + toolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), + conversationID: conversationID, + skipTrace: true, + middlewareConfig: &ma.EinoMiddleware, }), AgenticModelRetryConfig: agenticModelRetryCfg, AgenticModelFailoverConfig: agenticModelFailoverCfg, @@ -639,6 +651,7 @@ func RunDeepAgent( ModelFacingTrace: modelFacingTrace, EinoCallbacks: &ma.EinoCallbacks, MaxTotalTokens: appCfg.OpenAI.MaxTotalTokens, + TokenSafetyPercent: appCfg.OpenAI.TokenEstimateSafetyPercent(), ToolMaxBytes: toolMaxBytesFromMW(&ma.EinoMiddleware), ModelName: appCfg.OpenAI.Model, MiddlewareConfig: &ma.EinoMiddleware, diff --git a/internal/workflow/draft_generator.go b/internal/workflow/draft_generator.go index bfb8b6801..2152b00b8 100644 --- a/internal/workflow/draft_generator.go +++ b/internal/workflow/draft_generator.go @@ -303,11 +303,12 @@ func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenA {"role": "system", "content": systemPrompt}, {"role": "user", "content": userPrompt}, }, - "temperature": 0, - "max_completion_tokens": 4096, - "response_format": map[string]interface{}{"type": "json_object"}, - "thinking": map[string]interface{}{"type": "disabled"}, + "temperature": 0, + oa.OutputLimitField(): 4096, + "response_format": map[string]interface{}{"type": "json_object"}, + "thinking": map[string]interface{}{"type": "disabled"}, } + config.ApplyExtraRequestFields(requestBody, oa.Reasoning.ExtraRequestFields) var apiResponse struct { Choices []struct { Message struct { From 3964f325bd3ae5050bc68696dc21294e6c602247 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:13:01 +0800 Subject: [PATCH 2/7] feat(web): surface context window detection in AI channel settings --- web/static/css/style.css | 8 +++ web/static/i18n/en-US.json | 19 +++++ web/static/i18n/ru-RU.json | 19 +++++ web/static/i18n/zh-CN.json | 19 +++++ web/static/js/settings.js | 137 +++++++++++++++++++++++++++++++++++-- web/templates/index.html | 17 +++++ 6 files changed, 214 insertions(+), 5 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 742ce7498..b87b82d80 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5403,6 +5403,14 @@ html[data-theme="dark"] .conversation-reasoning-card:not(.conversation-reasoning line-height: 1.45; } +#detect-context-result.is-success { + color: var(--success-color, #38a169); +} + +#detect-context-result.is-error { + color: var(--danger-color, #e53e3e); +} + .ai-channel-reasoning-toggle { margin-top: 8px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ec2660f9e..2dbd473ba 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3093,6 +3093,25 @@ "maxCompletionTokens": "Max Output Tokens", "maxCompletionTokensPlaceholder": "32768", "maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 32768", + "contextWindow": "Model Context Window (tokens)", + "contextWindowPlaceholder": "131072", + "contextWindowHint": "Native model window used to validate and clamp the budget. For local models, match the server num_ctx/ctx-size.", + "contextProfile": "Context Optimization Profile", + "contextProfileAuto": "General (default)", + "contextProfileLocal": "Local model (32K-256K optimized)", + "contextProfileHint": "The local profile tightens compaction triggers, tool-output limits, and always-visible tools. It only tightens existing settings.", + "detectContext": "Auto-detect", + "detectContextFillRequired": "Fill in Base URL and model first", + "detectedContext": "Detected {tokens} tokens", + "effectiveBudget": "Effective budget {tokens} tokens", + "contextWarnWindowUnknown": "No window set for the local profile; assuming 32768", + "contextWarnWindowSmall": "Window below 16384; tool-using agents may be unreliable", + "contextWarnBudgetClamped": "Max context budget was clamped to the model window", + "contextWarnBudgetExceedsWindow": "Configured budget exceeds the model window; it will be clamped at runtime", + "contextWarnOllamaV1": "Ollama's /v1 endpoint ignores num_ctx; set OLLAMA_CONTEXT_LENGTH or a Modelfile parameter", + "contextWarnOllamaRuntime": "Ollama may cap the window below the model maximum based on VRAM; check ollama ps after loading", + "contextWarnRuntimeUnknown": "Runtime window could not be confirmed and may be below the model maximum", + "contextWarnUnknown": "No context window detected; enter it manually", "openaiReasoningTitle": "Reasoning settings", "openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.", "openaiReasoningProfile": "Wire profile", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 91d84d18d..9c186f4a5 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3106,6 +3106,25 @@ "maxCompletionTokens": "Макс. токенов ответа", "maxCompletionTokensPlaceholder": "32768", "maxCompletionTokensHint": "Максимум токенов одного ответа модели. По умолчанию: 32768", + "contextWindow": "Окно контекста модели (токены)", + "contextWindowPlaceholder": "131072", + "contextWindowHint": "Собственное окно модели для проверки и ограничения бюджета. Для локальных моделей укажите серверный num_ctx/ctx-size.", + "contextProfile": "Профиль оптимизации контекста", + "contextProfileAuto": "Общий (по умолчанию)", + "contextProfileLocal": "Локальная модель (32K-256K)", + "contextProfileHint": "Локальный профиль ужесточает пороги сжатия, лимиты вывода инструментов и число постоянных инструментов. Он только уменьшает существующие значения.", + "detectContext": "Автоопределение", + "detectContextFillRequired": "Сначала заполните Base URL и модель", + "detectedContext": "Обнаружено {tokens} токенов", + "effectiveBudget": "Эффективный бюджет {tokens} токенов", + "contextWarnWindowUnknown": "Для локального профиля не задано окно; используется 32768", + "contextWarnWindowSmall": "Окно меньше 16384; агент с инструментами может работать нестабильно", + "contextWarnBudgetClamped": "Бюджет контекста ограничен размером окна модели", + "contextWarnBudgetExceedsWindow": "Бюджет превышает окно модели и будет ограничен во время работы", + "contextWarnOllamaV1": "Эндпоинт Ollama /v1 игнорирует num_ctx; задайте OLLAMA_CONTEXT_LENGTH или параметр Modelfile", + "contextWarnOllamaRuntime": "Ollama может уменьшить окно ниже максимума модели в зависимости от VRAM; проверьте ollama ps", + "contextWarnRuntimeUnknown": "Не удалось подтвердить рабочее окно; оно может быть меньше максимума модели", + "contextWarnUnknown": "Окно контекста не обнаружено; укажите его вручную", "openaiReasoningTitle": "Настройки рассуждения", "openaiReasoningHint": "Настройки рассуждения этого канала ИИ по умолчанию; настройки сессии чата могут их переопределить.", "openaiReasoningProfile": "Профиль передачи", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c43d1be74..40c082827 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3081,6 +3081,25 @@ "maxCompletionTokens": "最大输出 Token 数", "maxCompletionTokensPlaceholder": "32768", "maxCompletionTokensHint": "单次模型回复的输出上限,默认 32768", + "contextWindow": "模型上下文窗口(Token)", + "contextWindowPlaceholder": "131072", + "contextWindowHint": "模型原生窗口;用于校验并收紧预算。本地模型请按服务端 num_ctx/ctx-size 填写。", + "contextProfile": "上下文优化档位", + "contextProfileAuto": "通用(默认)", + "contextProfileLocal": "本地模型(32K-256K 优化)", + "contextProfileHint": "本地档位会收紧压缩触发、工具输出阈值和常驻工具数;只收紧不放大已有配置。", + "detectContext": "自动检测", + "detectContextFillRequired": "请先填写 Base URL 和模型", + "detectedContext": "检测到 {tokens} tokens", + "effectiveBudget": "有效预算 {tokens} tokens", + "contextWarnWindowUnknown": "本地档位未填写窗口,按 32768 估算", + "contextWarnWindowSmall": "窗口小于 16384,带工具的 agent 可能无法稳定运行", + "contextWarnBudgetClamped": "最大上下文预算已按模型窗口收紧", + "contextWarnBudgetExceedsWindow": "填写的预算超过模型窗口,运行时会自动收紧", + "contextWarnOllamaV1": "Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile", + "contextWarnOllamaRuntime": "Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps", + "contextWarnRuntimeUnknown": "无法确认运行时窗口,可能小于模型上限", + "contextWarnUnknown": "未检测到上下文窗口,请手动填写", "openaiReasoningTitle": "推理设置", "openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。", "openaiReasoningProfile": "线路 profile", diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 0eb2eab9b..0bfcd0e8f 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -2626,6 +2626,8 @@ function ensureAIConfigShape(cfg) { model: oa.model || '', max_total_tokens: oa.max_total_tokens || 120000, max_completion_tokens: oa.max_completion_tokens || 0, + context_window: oa.context_window || 0, + context_profile: oa.context_profile || '', reasoning: oa.reasoning || {} }; } @@ -2644,6 +2646,8 @@ function readAIChannelFromMainForm(id) { model: document.getElementById('openai-model')?.value.trim() || '', max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 120000, max_completion_tokens: maxCompletionTokens, + context_window: parseInt(document.getElementById('openai-context-window')?.value, 10) || 0, + context_profile: document.getElementById('openai-context-profile')?.value || '', reasoning: { ...(prev.reasoning || {}), mode: document.getElementById('openai-reasoning-mode')?.value || 'auto', @@ -2676,6 +2680,10 @@ function writeAIChannelToMainForm(id) { if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000; const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens'); if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 32768; + const contextWindowEl = document.getElementById('openai-context-window'); + if (contextWindowEl) contextWindowEl.value = ch.context_window || ''; + const contextProfileEl = document.getElementById('openai-context-profile'); + if (contextProfileEl) contextProfileEl.value = ['auto', 'local'].includes(String(ch.context_profile || '').toLowerCase()) ? String(ch.context_profile).toLowerCase() : 'auto'; const r = ch.reasoning || {}; const modeEl = document.getElementById('openai-reasoning-mode'); if (modeEl) { @@ -2780,7 +2788,11 @@ function bindAIChannelEditorPreviewSync() { 'openai-provider', 'openai-api-key', 'openai-base-url', - 'openai-model' + 'openai-model', + 'openai-max-total-tokens', + 'openai-max-completion-tokens', + 'openai-context-window', + 'openai-context-profile' ]; ids.forEach((fieldId) => { const el = document.getElementById(fieldId); @@ -3128,6 +3140,8 @@ function createAIChannelFromForm() { model: '', max_total_tokens: 120000, max_completion_tokens: 32768, + context_window: 0, + context_profile: '', reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true } }; selectedAIChannelId = id; @@ -3222,14 +3236,17 @@ async function probeSelectedAIChannels() { channel_id: id, base_url: ch.base_url || '', api_key: ch.api_key || '', - model: ch.model || '' + model: ch.model || '', + max_total_tokens: parseInt(ch.max_total_tokens, 10) || 0, + context_profile: ch.context_profile || '' }) }); const result = await response.json().catch(() => ({})); if (response.ok && result.success) { okCount += 1; const latency = result.latency_ms ? ` ${result.latency_ms}ms` : ''; - aiChannelProbeResults[id] = { status: 'ready', message: settingsT('settingsBasic.aiChannelReadyWithLatency', '可用{latency}').replace('{latency}', latency) }; + const context = result.context_window ? ` · ${result.context_window} tokens` : ''; + aiChannelProbeResults[id] = { status: 'ready', message: settingsT('settingsBasic.aiChannelReadyWithLatency', '可用{latency}').replace('{latency}', latency) + context }; } else { aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(result.error || settingsT('settingsBasic.testFailed', '连接失败')).message }; } @@ -3291,6 +3308,7 @@ if (typeof window !== 'undefined') { window.copyAIChannelFromForm = copyAIChannelFromForm; window.deleteSelectedAIChannel = deleteSelectedAIChannel; window.probeSelectedAIChannels = probeSelectedAIChannels; + window.detectAIContext = detectAIContext; window.deleteCheckedAIChannels = deleteCheckedAIChannels; } @@ -3877,6 +3895,111 @@ function showConnectionTestFailure(resultEl, errorText) { } // 测试OpenAI连接 +function contextWarningText(code) { + const messages = { + local_context_window_unknown: settingsT('settingsBasic.contextWarnWindowUnknown', '本地档位未填写窗口,按 32768 估算'), + local_context_window_small: settingsT('settingsBasic.contextWarnWindowSmall', '窗口小于 16384,带工具的 agent 可能无法稳定运行'), + max_total_tokens_clamped: settingsT('settingsBasic.contextWarnBudgetClamped', '最大上下文预算已按模型窗口收紧'), + configured_budget_exceeds_context_window: settingsT('settingsBasic.contextWarnBudgetExceedsWindow', '填写的预算超过模型窗口,运行时会自动收紧'), + ollama_v1_ignores_num_ctx: settingsT('settingsBasic.contextWarnOllamaV1', 'Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile'), + ollama_runtime_context_unknown: settingsT('settingsBasic.contextWarnOllamaRuntime', 'Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps'), + context_runtime_unknown: settingsT('settingsBasic.contextWarnRuntimeUnknown', '无法确认运行时窗口,可能小于模型上限'), + context_window_unknown: settingsT('settingsBasic.contextWarnUnknown', '未检测到上下文窗口,请手动填写') + }; + return messages[code] || code; +} + +function formatDetectedContext(result) { + if (!result || !result.context_window) return ''; + const parts = [settingsT('settingsBasic.detectedContext', '检测到 {tokens} tokens').replace('{tokens}', String(result.context_window))]; + if (result.effective_max_total_tokens) { + parts.push(settingsT('settingsBasic.effectiveBudget', '有效预算 {tokens} tokens').replace('{tokens}', String(result.effective_max_total_tokens))); + } + if (result.context_backend) parts.push(result.context_backend); + if (result.context_source) parts.push(result.context_source); + if (Array.isArray(result.context_warnings)) { + result.context_warnings.forEach((code) => parts.push(contextWarningText(code))); + } + return parts.join(' · '); +} + +function applyDetectedContext(result) { + if (!result || !result.context_window) return; + const input = document.getElementById('openai-context-window'); + if (input && !String(input.value || '').trim()) { + input.value = String(result.context_window); + } + const resultEl = document.getElementById('detect-context-result'); + if (resultEl) { + resultEl.textContent = formatDetectedContext(result); + resultEl.className = 'form-inline-result is-success'; + } +} + +async function detectAIContext() { + const btn = document.getElementById('detect-context-btn'); + const resultEl = document.getElementById('detect-context-result'); + const provider = document.getElementById('openai-provider')?.value || 'openai_compatible'; + const baseUrl = document.getElementById('openai-base-url')?.value.trim() || ''; + const apiKey = document.getElementById('openai-api-key')?.value.trim() || ''; + const model = document.getElementById('openai-model')?.value.trim() || ''; + const channelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default'); + if (!baseUrl || !model) { + if (resultEl) { + resultEl.textContent = settingsT('settingsBasic.detectContextFillRequired', '请先填写 Base URL 和模型'); + resultEl.className = 'form-inline-result is-error'; + } + return; + } + if (btn) { + btn.style.pointerEvents = 'none'; + btn.style.opacity = '0.5'; + } + if (resultEl) { + resultEl.textContent = settingsT('settingsBasic.testing', '测试中...'); + resultEl.className = 'form-inline-result is-muted'; + } + try { + const response = await apiFetch('/api/config/detect-context', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: provider, + channel_id: channelId, + base_url: baseUrl, + api_key: apiKey, + model: model, + max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 0, + context_profile: document.getElementById('openai-context-profile')?.value || '' + }) + }); + const result = await response.json().catch(() => ({})); + if (result.success && result.context_window) { + applyDetectedContext(result); + if (resultEl) { + resultEl.textContent = formatDetectedContext(result); + resultEl.className = 'form-inline-result is-success'; + } + } else { + const message = result.error || contextWarningText('context_window_unknown'); + if (resultEl) { + resultEl.textContent = formatConnectionTestError(message).message; + resultEl.className = 'form-inline-result is-error'; + } + } + } catch (error) { + if (resultEl) { + resultEl.textContent = formatConnectionTestError(error.message || '测试出错').message; + resultEl.className = 'form-inline-result is-error'; + } + } finally { + if (btn) { + btn.style.pointerEvents = ''; + btn.style.opacity = ''; + } + } +} + async function testOpenAIConnection() { const btn = document.getElementById('test-openai-btn'); const resultEl = document.getElementById('test-openai-result'); @@ -3918,7 +4041,9 @@ async function testOpenAIConnection() { channel_id: channelId, base_url: baseUrl, api_key: apiKey, - model: model + model: model, + max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 0, + context_profile: document.getElementById('openai-context-profile')?.value || '' }) }); @@ -3927,9 +4052,11 @@ async function testOpenAIConnection() { if (result.success) { const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : ''; const modelInfo = result.model ? ` [${result.model}]` : ''; - const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency; + const contextInfo = result.context_window ? ` · ${result.context_window} tokens` : ''; + const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency + contextInfo; aiChannelProbeResults[channelId] = { status: 'ready', message }; if (isTestingSameChannel()) { + applyDetectedContext(result); setConnectionTestResult(resultEl, 'is-success', message); } } else { diff --git a/web/templates/index.html b/web/templates/index.html index a8091752d..b00c837be 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3764,6 +3764,23 @@
额度设置
单次模型回复的输出上限,默认 16384 +
+ +
+ + 自动检测 +
+ 模型原生窗口;用于校验并收紧预算。本地模型请按服务端 num_ctx/ctx-size 填写。 + +
+
+ + + 本地档位会收紧压缩触发、工具输出阈值和常驻工具数;只收紧不放大已有配置。 +
From 0be25994a3e9e6c6b254ab59dc51782135bae96a Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:13:01 +0800 Subject: [PATCH 3/7] docs: document local model context tuning and runtime window detection --- CHANGELOG.md | 3 +++ config.example.yaml | 21 +++++++++++++++++++++ docs/en-US/configuration.md | 18 ++++++++++++++++++ docs/zh-CN/configuration.md | 18 ++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2820daf..1f1b29f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- 本地大模型上下文优化:AI 通道新增 `context_window` 与 `context_profile: local`,支持从 Ollama / llama.cpp / LM Studio / vLLM 自动探测运行时窗口,按窗口收紧压缩预算与工具输出阈值,并让内置工具进入 `tool_search` 动态池;local 档位自动改用 `max_tokens` 兼容本地端点。 +- 本地上下文增强:检测结果展示按档位折算后的有效预算,探测支持反向代理子路径;local 档位为 token 估算保留 10% 余量,补充 llama.cpp / LM Studio / vLLM 的超窗报错识别并触发激进压缩重试;攻击链、FOFA、工作流、HITL 等旁路调用同样按档位切换输出字段,并合并通道级 `reasoning.extra_request_fields`。 + - 启动时以统一最新 SQLite 定义自动检查全部项目管理表,缺表建表、缺字段补字段,无需维护逐字段迁移列表;保留已有数据和额外表/字段,结构补齐失败回滚并阻止启动。HITL 与通知表纳入统一初始化,历史漏洞外键迁移保留额外列、索引、触发器及依赖记录。 ### Fixed diff --git a/config.example.yaml b/config.example.yaml index a25f1882d..cad4688b9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -108,6 +108,8 @@ ai: model: qwen3-max max_total_tokens: 120000 max_completion_tokens: 32768 + context_window: 262144 # 模型原生上下文窗口(token);0/省略=未知。检测到窗口后,预算会被自动收紧 + context_profile: auto # auto=通用;local=本地模型档位(32K-256K),会收紧压缩/工具输出/常驻工具,token 估算留 10% 保守余量 # Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields reasoning: mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考) @@ -123,6 +125,24 @@ ai: # model: qwen-plus # max_total_tokens: 120000 # max_completion_tokens: 32768 + # 本地模型示例(Ollama / llama.cpp / vLLM / LM Studio 均走 OpenAI 兼容端点) + # local-qwen: + # name: Local Qwen + # provider: openai_compatible + # base_url: http://127.0.0.1:11434/v1 + # api_key: ollama + # model: qwen3.5:9b + # max_total_tokens: 65536 # 运行时会再被 context_window * 60% 收紧 + # max_completion_tokens: 8192 + # context_window: 131072 # 建议用页面「自动检测」填入实际 runtime 窗口 + # context_profile: local + # # Ollama 的 /v1 接口忽略 num_ctx:请同时设置 OLLAMA_CONTEXT_LENGTH=131072 或 Modelfile PARAMETER num_ctx; + # # 否则 Ollama 会按显存使用 4K/32K/256K 默认值,并从开头截断系统提示词。 + # reasoning: + # mode: off + # extra_request_fields: + # chat_template_kwargs: + # enable_thinking: false # Qwen3.5 等思考模型在 agent 工具循环中建议关闭思考 # 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要) vision: enabled: false # true 且 model 非空时注册 analyze_image @@ -342,6 +362,7 @@ multi_agent: tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用 tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁 tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, get_tool_execution, wait_tool_execution, cancel_tool_execution, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略) + tool_search_include_builtins: true # false:后端内置工具也进入 tool_search 动态池;context_profile=local 时自动为 false plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在 plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask// reduction_enable: true # true:大工具输出截断并落盘到本地文件(tmp/reduction/.../trunc/),上下文只留 预览;不依赖 eino_skills diff --git a/docs/en-US/configuration.md b/docs/en-US/configuration.md index d46c00871..b18d9a76e 100644 --- a/docs/en-US/configuration.md +++ b/docs/en-US/configuration.md @@ -72,8 +72,26 @@ ai: | `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. | | `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. | | `max_completion_tokens` | Per-response output cap; default is used when empty. | +| `context_window` | Native model context window in tokens. When set, the runtime clamps `max_total_tokens` to it; 0/omitted means unknown. | +| `context_profile` | `auto` (default) or `local`. `local` targets 32K–256K local models: the budget is 60% of the window (capped at 128K), while summary reserve, tool-output truncation/clearing, user-message and ledger caps are tightened, and `tool_search` with a small always-visible tool set is enabled. It only tightens numeric middleware settings and enables tool_search/reduction where needed. | | `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. | +### Local Model Context (32K–256K) + +A local model's effective context is usually much smaller than its advertised window, and the “model maximum” can differ from the runtime window. Recommended setup: + +1. **Confirm the runtime window**: click **Auto-detect** next to the context window field in **System Settings → Basic Settings → AI Channel Configuration**. The probe checks Ollama `/api/ps`/`/api/show`, llama.cpp `/props`, LM Studio `/api/v0/models`, then vLLM/OpenAI-compatible `/v1/models`; reverse-proxy subpaths such as `https://host/ollama/v1` are supported. The result also shows the effective budget for the current profile. Enter the server's actual value if detection fails. +2. **Enable the local profile**: set `context_profile: local`. The budget becomes 60% of the window and compaction triggers earlier; tool-output truncation is roughly window/6 (8–32 KB), historical tool-result clearing is roughly 35% of the budget, and always-visible tools shrink to a core set; backend builtins also join the `tool_search` dynamic pool, and the rest are unlocked on demand. Token estimates are inflated by 10% to absorb tokenizer differences between local models and tiktoken. The profile also switches the output cap to the more widely accepted `max_tokens` field, because many local servers do not recognize `max_completion_tokens`; the same applies to attack-chain, FOFA query, workflow draft, and HITL audit calls. +3. **Do not plan around a nominal 1M window**: Qwen3.5-class models ship with 262,144 native context and can be extended to 1M with YaRN, but local VRAM limits and context rot degrade quality. For tool-using agents, keep the effective budget around 64K–128K. +4. **Ollama requires `num_ctx`**: Ollama's `/v1` compatibility endpoint ignores `num_ctx` in requests and picks a default window from VRAM (roughly 4K/32K/256K). Oversized prompts are truncated from the beginning, dropping the system prompt and tool definitions first. Use `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` or a Modelfile `PARAMETER num_ctx 65536`, then confirm `CONTEXT` in `ollama ps` after restart. +5. **KV cache and prefix caching**: + - llama.cpp: `--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`; `--cache-prompt` is on by default. `--parallel` splits the context across slots. + - vLLM: `--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`; use `--max-num-seqs` to bound concurrent KV usage. + - Ollama: `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_NUM_PARALLEL=1`; larger windows consume more KV cache. + - LM Studio: set Context Length explicitly in the model settings and enable KV-cache quantization when available. +6. **Thinking models**: Qwen3.5-class models enable thinking by default, which burns context inside tool loops. Disable it per channel with `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` (supported by vLLM and llama.cpp with `--jinja`). `reasoning.mode: off` only clears OpenAI/DeepSeek reasoning fields and may not disable the local chat template's thinking. +7. **Minimum viable window**: an agent with 90+ tools is unreliable below 16K. 32K depends on `tool_search` and `context_profile: local`; 64K and above is closer to a cloud-model experience. + The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`. Common Web UI operations: diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 721228af7..5137f6f0f 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -73,8 +73,26 @@ ai: | `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 | | `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 | | `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 | +| `context_window` | 模型原生上下文窗口(token)。填写后运行时会按窗口收紧 `max_total_tokens`;0/省略表示未知。 | +| `context_profile` | `auto`(默认)或 `local`。`local` 面向 32K–256K 本地模型:预算折到窗口的 60%(上限 128K),收紧摘要预留、工具输出截断/清理、用户消息与账本上限,并启用 `tool_search` 与精简常驻工具。数值参数只收紧、不放大,同时按需启用 tool_search/reduction。 | | `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 | +### 本地模型(32K–256K)上下文要点 + +本地模型的有效上下文通常明显小于标称窗口,且不同后端的“模型上限”和“运行时窗口”可能不一致。建议: + +1. **先确认运行时窗口**:在 **系统设置 → 基本设置 → AI 通道配置 → 模型上下文窗口** 右侧点击「自动检测」。探测顺序为 Ollama `/api/ps`、`/api/show`,llama.cpp `/props`,LM Studio `/api/v0/models`,vLLM/OpenAI 兼容 `/v1/models`;同时支持反向代理子路径(如 `https://host/ollama/v1`)。检测结果会显示按当前档位折算后的有效预算。检测不到时请手动填写服务端实际配置。 +2. **启用本地档位**:把 `context_profile` 设为 `local`。预算按窗口 60% 计算,摘要更早触发;工具输出截断阈值约为窗口/6(8–32 KB),历史工具结果清理阈值约为预算的 35%;常驻工具收敛到核心集合,内置工具也进入 `tool_search` 动态池,其余工具按需解锁。token 估算会额外放大 10%,抵消本地 tokenizer 与 tiktoken 在中英文上的差异。该档位还会把输出上限字段切换为更通用的 `max_tokens`(Ollama/llama.cpp 等常不识别 `max_completion_tokens`),攻击链、FOFA 语法生成、工作流草稿、HITL 审计等旁路调用同样生效。 +3. **不要按标称 1M 规划**:Qwen3.5 等模型原生 262,144,可用 YaRN 扩到 1M,但本地显存与长上下文性能衰减(context rot)会让实际效果明显下降;带工具的 agent 建议把有效预算控制在 64K–128K。 +4. **Ollama 必须设置 `num_ctx`**:Ollama 的 `/v1` 兼容端点会忽略请求里的 `num_ctx`,并按显存选择默认窗口(约 4K/32K/256K);超长时从开头截断,系统提示词和工具定义会先丢失。请用 `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` 或 Modelfile `PARAMETER num_ctx 65536`,重启后用 `ollama ps` 确认 `CONTEXT`。 +5. **KV cache 与前缀缓存**: + - llama.cpp:`--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`;`--cache-prompt` 默认开启。`--parallel` 会让每个槽位分摊上下文。 + - vLLM:`--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`;用 `--max-num-seqs` 控制并发 KV 占用。 + - Ollama:`OLLAMA_KV_CACHE_TYPE=q8_0`、`OLLAMA_NUM_PARALLEL=1`;窗口越大 KV cache 越大。 + - LM Studio:在模型设置中显式设置 Context Length,并开启可用的 KV cache 量化。 +6. **思考模型**:Qwen3.5 等默认开启 thinking,会在工具循环中消耗大量上下文。可通过通道的 `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` 关闭(vLLM、llama.cpp `--jinja` 支持);`reasoning.mode: off` 只清理 OpenAI/DeepSeek 推理字段,不一定能关闭本地 chat template 的 thinking。 +7. **建议窗口下限**:带 90+ 工具的 agent 在 16K 以下很难稳定运行;32K 需要依赖 `tool_search` 和 `context_profile: local`;64K 以上更接近云端模型的使用体验。 + 对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。 常用操作: From 0d97169d07c28a12dfb739203a98e311f69af887 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:16:30 +0800 Subject: [PATCH 4/7] fix(ai): report the configured budget in context clamp warnings The warning was computed after the runtime config had already been clamped, so configured and effective values were identical and the clamp warning was lost. --- internal/multiagent/context_budget.go | 20 +++++--- internal/multiagent/context_budget_test.go | 59 ++++++++++++++++++++++ internal/multiagent/eino_single_runner.go | 3 +- internal/multiagent/runner.go | 3 +- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/internal/multiagent/context_budget.go b/internal/multiagent/context_budget.go index 96088a7e9..370370022 100644 --- a/internal/multiagent/context_budget.go +++ b/internal/multiagent/context_budget.go @@ -17,23 +17,31 @@ import ( var contextBudgetWarned sync.Map -func logContextBudgetWarnings(oa config.OpenAIConfig, logger *zap.Logger, phase string) { +// logContextBudgetWarnings reports the clamp applied by WithEffectiveContextBudget. +// configured must be the pre-clamp channel config so the warning set and the +// configured value stay meaningful. +func logContextBudgetWarnings(configured, effective config.OpenAIConfig, logger *zap.Logger, phase string) { if logger == nil { return } - warnings := oa.ContextBudgetWarnings() + warnings := configured.ContextBudgetWarnings() if len(warnings) == 0 { return } - key := fmt.Sprintf("%d|%d|%s", oa.EffectiveContextWindow(), oa.MaxTotalTokens, strings.Join(warnings, ",")) + key := fmt.Sprintf("%d|%d|%d|%s", + configured.EffectiveContextWindow(), + configured.MaxTotalTokens, + effective.MaxTotalTokens, + strings.Join(warnings, ","), + ) if _, loaded := contextBudgetWarned.LoadOrStore(key, struct{}{}); loaded { return } logger.Warn("model context budget adjusted", zap.String("phase", phase), - zap.Int("context_window", oa.EffectiveContextWindow()), - zap.Int("configured_max_total_tokens", oa.MaxTotalTokens), - zap.Int("effective_max_total_tokens", oa.EffectiveMaxTotalTokens()), + zap.Int("context_window", configured.EffectiveContextWindow()), + zap.Int("configured_max_total_tokens", configured.MaxTotalTokens), + zap.Int("effective_max_total_tokens", effective.MaxTotalTokens), zap.Strings("warnings", warnings), ) } diff --git a/internal/multiagent/context_budget_test.go b/internal/multiagent/context_budget_test.go index 7e6575926..c285e398c 100644 --- a/internal/multiagent/context_budget_test.go +++ b/internal/multiagent/context_budget_test.go @@ -11,6 +11,9 @@ import ( "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk/middlewares/summarization" "github.com/cloudwego/eino/schema" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) func TestIsEinoContextOverflowError(t *testing.T) { @@ -89,6 +92,62 @@ func TestBuildBudgetedSummarizationModelInputTruncatesOversizedLatestRound(t *te } } +func TestLogContextBudgetWarningsKeepsConfiguredValue(t *testing.T) { + t.Parallel() + core, logs := observer.New(zapcore.WarnLevel) + logger := zap.New(core) + configured := config.OpenAIConfig{ + ContextWindow: 131073, + ContextProfile: "local", + MaxTotalTokens: 120000, + } + effective := configured + effective.MaxTotalTokens = configured.EffectiveMaxTotalTokens() + logContextBudgetWarnings(configured, effective, logger, "test") + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("warn entries = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + if got, ok := fields["configured_max_total_tokens"].(int64); !ok || got != 120000 { + t.Fatalf("configured_max_total_tokens = %#v, want 120000", fields["configured_max_total_tokens"]) + } + if got, ok := fields["effective_max_total_tokens"].(int64); !ok || got != 78643 { + t.Fatalf("effective_max_total_tokens = %#v, want 78643", fields["effective_max_total_tokens"]) + } + warnings := warningCodes(fields["warnings"]) + if !hasWarningCode(warnings, config.ContextWarningMaxTotalClamped) { + t.Fatalf("warnings = %#v, want clamp warning", fields["warnings"]) + } +} + +func warningCodes(value interface{}) []string { + switch v := value.(type) { + case []string: + return v + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if code, ok := item.(string); ok { + out = append(out, code) + } + } + return out + default: + return nil + } +} + +func hasWarningCode(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + func TestLocalTokenCounterSafetyMargin(t *testing.T) { t.Parallel() input := &summarization.TokenCounterInput{Messages: []adk.Message{ diff --git a/internal/multiagent/eino_single_runner.go b/internal/multiagent/eino_single_runner.go index d3f545ba8..58d34c2b4 100644 --- a/internal/multiagent/eino_single_runner.go +++ b/internal/multiagent/eino_single_runner.go @@ -45,13 +45,14 @@ func RunEinoSingleChatModelAgent( if ma == nil { return nil, fmt.Errorf("eino single: multi_agent 配置为空") } + configuredOpenAI := appCfg.OpenAI appCfg = appCfg.WithEffectiveContextBudget() { maCopy := *ma maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware ma = &maCopy } - logContextBudgetWarnings(appCfg.OpenAI, logger, "eino_single") + logContextBudgetWarnings(configuredOpenAI, appCfg.OpenAI, logger, "eino_single") runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index 47924da26..d8406ee41 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -84,13 +84,14 @@ func RunDeepAgent( return nil, fmt.Errorf("multiagent: 配置或 Agent 为空") } + configuredOpenAI := appCfg.OpenAI appCfg = appCfg.WithEffectiveContextBudget() if ma != nil { maCopy := *ma maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware ma = &maCopy } - logContextBudgetWarnings(appCfg.OpenAI, logger, "deep") + logContextBudgetWarnings(configuredOpenAI, appCfg.OpenAI, logger, "deep") runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) From 77711616b2cf5e0908525ceb96062cf10ca484fc Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:16:48 +0800 Subject: [PATCH 5/7] test(ai): cover idempotent effective context budget --- internal/config/context_profile_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/config/context_profile_test.go b/internal/config/context_profile_test.go index bbb83f502..23c288600 100644 --- a/internal/config/context_profile_test.go +++ b/internal/config/context_profile_test.go @@ -290,6 +290,28 @@ func TestEffectiveEinoMiddlewareForChannelAutoUnchanged(t *testing.T) { } } +func TestWithEffectiveContextBudgetIsIdempotent(t *testing.T) { + t.Parallel() + cfg := &Config{ + OpenAI: OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 120000}, + MultiAgent: MultiAgentConfig{ + EinoMiddleware: MultiAgentEinoMiddlewareConfig{ + SummarizationTriggerRatio: 0.9, + SummarizationOutputReserveTokens: 40960, + ToolSearchAlwaysVisibleTools: []string{"read_file", "webshell_exec"}, + }, + }, + } + once := cfg.WithEffectiveContextBudget() + twice := once.WithEffectiveContextBudget() + if !reflect.DeepEqual(once.OpenAI, twice.OpenAI) { + t.Fatalf("OpenAI not idempotent: %#v vs %#v", once.OpenAI, twice.OpenAI) + } + if !reflect.DeepEqual(once.MultiAgent.EinoMiddleware, twice.MultiAgent.EinoMiddleware) { + t.Fatalf("middleware not idempotent: %#v vs %#v", once.MultiAgent.EinoMiddleware, twice.MultiAgent.EinoMiddleware) + } +} + func TestWithEffectiveContextBudgetDoesNotMutateSource(t *testing.T) { t.Parallel() cfg := &Config{ From da0cb2a106b479aeb624d1d077929bafc5f156d0 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:17:46 +0800 Subject: [PATCH 6/7] docs(api): document the model context detection endpoint --- internal/handler/openapi.go | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 2deea5e72..6abc4003d 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -5064,6 +5064,60 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/config/detect-context": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"配置管理"}, + "summary": "探测模型运行时上下文窗口", + "description": "探测 Ollama、llama.cpp、LM Studio、vLLM/OpenAI 兼容端点的运行时上下文窗口,并按当前档位计算有效预算;不会发起推理请求。", + "operationId": "detectModelContext", + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "required": []string{"model"}, + "properties": map[string]interface{}{ + "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai_compatible/claude等)", "example": "openai_compatible"}, + "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认 OpenAI)", "example": "http://127.0.0.1:11434/v1"}, + "api_key": map[string]interface{}{"type": "string", "description": "API密钥(可选,可回退到已保存通道)"}, + "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "qwen3:8b"}, + "channel_id": map[string]interface{}{"type": "string", "description": "已保存的 AI 通道 ID(用于复用密钥)"}, + "credential_scope": map[string]interface{}{"type": "string", "description": "凭据作用域(可选)"}, + "max_total_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的最大上下文预算,用于计算有效预算与告警"}, + "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "上下文优化档位"}, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "探测结果", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "success": map[string]interface{}{"type": "boolean", "description": "探测是否执行成功"}, + "model": map[string]interface{}{"type": "string", "description": "被探测的模型"}, + "context_backend": map[string]interface{}{"type": "string", "description": "识别到的后端类型(ollama/llamacpp/lmstudio/openai_compatible)"}, + "context_window": map[string]interface{}{"type": "integer", "description": "运行时上下文窗口(token)"}, + "context_source": map[string]interface{}{"type": "string", "description": "窗口来源(如 ollama_ps、llamacpp_props)"}, + "context_loaded": map[string]interface{}{"type": "boolean", "description": "窗口是否来自已加载实例"}, + "context_runtime_unknown": map[string]interface{}{"type": "boolean", "description": "只能确认模型上限,无法确认运行时窗口"}, + "effective_max_total_tokens": map[string]interface{}{"type": "integer", "description": "按 context_profile 折算后的有效预算"}, + "context_warnings": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "告警码列表(如 ollama_v1_ignores_num_ctx、max_total_tokens_clamped)"}, + }, + }, + }, + }, + }, + "400": map[string]interface{}{"description": "参数错误"}, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, "/api/config/test-typesafe": map[string]interface{}{ "post": map[string]interface{}{ "tags": []string{"配置管理"}, From 8b74b33eee5e29fb96a54dca0159fbedd6e56f95 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:47:03 +0800 Subject: [PATCH 7/7] fix(ai): harden local context detection and budget previews - Surface llama.cpp parallel slots via context_slots and a warning instead of treating n_ctx as the per-request window - Pass max_completion_tokens into detection previews and warn when the output cap is clamped - Clamp the effective budget to the window and use the assumed local window when clamping output - Overwrite a stale window on explicit auto-detect so the preview matches the saved value - Keep the budget-warning test repeatable and document failover/budget semantics --- docs/en-US/configuration.md | 9 ++-- docs/zh-CN/configuration.md | 9 ++-- internal/config/config.go | 4 +- internal/config/context_profile.go | 13 ++++-- internal/config/context_profile_test.go | 16 +++++++ internal/handler/config.go | 50 ++++++++++++---------- internal/handler/config_context_test.go | 21 +++++++-- internal/handler/openapi.go | 41 +++++++++++------- internal/modelmeta/context.go | 10 +++++ internal/modelmeta/context_test.go | 42 ++++++++++++++++++ internal/multiagent/context_budget_test.go | 12 ++++-- web/static/i18n/en-US.json | 2 + web/static/i18n/ru-RU.json | 2 + web/static/i18n/zh-CN.json | 2 + web/static/js/settings.js | 17 +++++--- 15 files changed, 185 insertions(+), 65 deletions(-) diff --git a/docs/en-US/configuration.md b/docs/en-US/configuration.md index b18d9a76e..34017e067 100644 --- a/docs/en-US/configuration.md +++ b/docs/en-US/configuration.md @@ -70,8 +70,8 @@ ai: | `name` | Display name in the Web UI; falls back to the ID. | | `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels use Eino's native Anthropic Messages API component. | | `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. | -| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. | -| `max_completion_tokens` | Per-response output cap; default is used when empty. | +| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. When `context_window` is known, it is clamped to the window minus the output reserve. | +| `max_completion_tokens` | Per-response output cap; default is used when empty, and it is clamped to half of the known/assumed window. | | `context_window` | Native model context window in tokens. When set, the runtime clamps `max_total_tokens` to it; 0/omitted means unknown. | | `context_profile` | `auto` (default) or `local`. `local` targets 32K–256K local models: the budget is 60% of the window (capped at 128K), while summary reserve, tool-output truncation/clearing, user-message and ledger caps are tightened, and `tool_search` with a small always-visible tool set is enabled. It only tightens numeric middleware settings and enables tool_search/reduction where needed. | | `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. | @@ -81,16 +81,17 @@ ai: A local model's effective context is usually much smaller than its advertised window, and the “model maximum” can differ from the runtime window. Recommended setup: 1. **Confirm the runtime window**: click **Auto-detect** next to the context window field in **System Settings → Basic Settings → AI Channel Configuration**. The probe checks Ollama `/api/ps`/`/api/show`, llama.cpp `/props`, LM Studio `/api/v0/models`, then vLLM/OpenAI-compatible `/v1/models`; reverse-proxy subpaths such as `https://host/ollama/v1` are supported. The result also shows the effective budget for the current profile. Enter the server's actual value if detection fails. -2. **Enable the local profile**: set `context_profile: local`. The budget becomes 60% of the window and compaction triggers earlier; tool-output truncation is roughly window/6 (8–32 KB), historical tool-result clearing is roughly 35% of the budget, and always-visible tools shrink to a core set; backend builtins also join the `tool_search` dynamic pool, and the rest are unlocked on demand. Token estimates are inflated by 10% to absorb tokenizer differences between local models and tiktoken. The profile also switches the output cap to the more widely accepted `max_tokens` field, because many local servers do not recognize `max_completion_tokens`; the same applies to attack-chain, FOFA query, workflow draft, and HITL audit calls. +2. **Enable the local profile**: set `context_profile: local`. The budget becomes 60% of the window, also bounded by “window − output reserve” (for example, a 32K window with the default 16K output cap yields a 16K budget), and the per-request output cap is clamped to half the window; compaction triggers earlier. Tool-output truncation is roughly window/6 (8–32 KB), historical tool-result clearing is roughly 35% of the budget, and always-visible tools shrink to a core set; backend builtins also join the `tool_search` dynamic pool, and the rest are unlocked on demand. Token estimates are inflated by 10% to absorb tokenizer differences between local models and tiktoken. The profile also switches the output cap to the more widely accepted `max_tokens` field, because many local servers do not recognize `max_completion_tokens`; the same applies to attack-chain, FOFA query, workflow draft, and HITL audit calls. 3. **Do not plan around a nominal 1M window**: Qwen3.5-class models ship with 262,144 native context and can be extended to 1M with YaRN, but local VRAM limits and context rot degrade quality. For tool-using agents, keep the effective budget around 64K–128K. 4. **Ollama requires `num_ctx`**: Ollama's `/v1` compatibility endpoint ignores `num_ctx` in requests and picks a default window from VRAM (roughly 4K/32K/256K). Oversized prompts are truncated from the beginning, dropping the system prompt and tool definitions first. Use `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` or a Modelfile `PARAMETER num_ctx 65536`, then confirm `CONTEXT` in `ollama ps` after restart. 5. **KV cache and prefix caching**: - - llama.cpp: `--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`; `--cache-prompt` is on by default. `--parallel` splits the context across slots. + - llama.cpp: `--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`; `--cache-prompt` is on by default. `--parallel` splits the context across slots; detection returns `context_slots` and warns when it is greater than 1, so confirm the per-request window for your KV mode (for example `--kv-unified`). - vLLM: `--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`; use `--max-num-seqs` to bound concurrent KV usage. - Ollama: `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_NUM_PARALLEL=1`; larger windows consume more KV cache. - LM Studio: set Context Length explicitly in the model settings and enable KV-cache quantization when available. 6. **Thinking models**: Qwen3.5-class models enable thinking by default, which burns context inside tool loops. Disable it per channel with `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` (supported by vLLM and llama.cpp with `--jinja`). `reasoning.mode: off` only clears OpenAI/DeepSeek reasoning fields and may not disable the local chat template's thinking. 7. **Minimum viable window**: an agent with 90+ tools is unreliable below 16K. 32K depends on `tool_search` and `context_profile: local`; 64K and above is closer to a cloud-model experience. +8. **Failover channels are not re-budgeted**: `multi_agent.eino_middleware.model_failover_channels` only switches the model endpoint; the budget still follows the primary channel. Keep each failover channel's available window at least as large as the primary, or expect overflow compaction/retry. The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`. diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 5137f6f0f..8c241f8bb 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -71,8 +71,8 @@ ai: | `name` | Web UI 展示名。留空时使用通道 ID。 | | `provider` | `openai_compatible` 或 `claude`。`openai_compatible` 会在运行时映射为 `openai`;`claude` 使用 Eino 原生 Anthropic Messages API。 | | `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 | -| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 | -| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 | +| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。已知 `context_window` 时会收紧到“窗口 − 输出预留”。 | +| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值,并会被压到已知/假定窗口的一半以内。 | | `context_window` | 模型原生上下文窗口(token)。填写后运行时会按窗口收紧 `max_total_tokens`;0/省略表示未知。 | | `context_profile` | `auto`(默认)或 `local`。`local` 面向 32K–256K 本地模型:预算折到窗口的 60%(上限 128K),收紧摘要预留、工具输出截断/清理、用户消息与账本上限,并启用 `tool_search` 与精简常驻工具。数值参数只收紧、不放大,同时按需启用 tool_search/reduction。 | | `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 | @@ -82,16 +82,17 @@ ai: 本地模型的有效上下文通常明显小于标称窗口,且不同后端的“模型上限”和“运行时窗口”可能不一致。建议: 1. **先确认运行时窗口**:在 **系统设置 → 基本设置 → AI 通道配置 → 模型上下文窗口** 右侧点击「自动检测」。探测顺序为 Ollama `/api/ps`、`/api/show`,llama.cpp `/props`,LM Studio `/api/v0/models`,vLLM/OpenAI 兼容 `/v1/models`;同时支持反向代理子路径(如 `https://host/ollama/v1`)。检测结果会显示按当前档位折算后的有效预算。检测不到时请手动填写服务端实际配置。 -2. **启用本地档位**:把 `context_profile` 设为 `local`。预算按窗口 60% 计算,摘要更早触发;工具输出截断阈值约为窗口/6(8–32 KB),历史工具结果清理阈值约为预算的 35%;常驻工具收敛到核心集合,内置工具也进入 `tool_search` 动态池,其余工具按需解锁。token 估算会额外放大 10%,抵消本地 tokenizer 与 tiktoken 在中英文上的差异。该档位还会把输出上限字段切换为更通用的 `max_tokens`(Ollama/llama.cpp 等常不识别 `max_completion_tokens`),攻击链、FOFA 语法生成、工作流草稿、HITL 审计等旁路调用同样生效。 +2. **启用本地档位**:把 `context_profile` 设为 `local`。预算按窗口 60% 计算,同时不超过“窗口 − 输出预留”(例如 32K 窗口、默认输出上限 16K 时预算为 16K);单次输出上限也会被压到窗口的一半以内。摘要更早触发;工具输出截断阈值约为窗口/6(8–32 KB),历史工具结果清理阈值约为预算的 35%;常驻工具收敛到核心集合,内置工具也进入 `tool_search` 动态池,其余工具按需解锁。token 估算会额外放大 10%,抵消本地 tokenizer 与 tiktoken 在中英文上的差异。该档位还会把输出上限字段切换为更通用的 `max_tokens`(Ollama/llama.cpp 等常不识别 `max_completion_tokens`),攻击链、FOFA 语法生成、工作流草稿、HITL 审计等旁路调用同样生效。 3. **不要按标称 1M 规划**:Qwen3.5 等模型原生 262,144,可用 YaRN 扩到 1M,但本地显存与长上下文性能衰减(context rot)会让实际效果明显下降;带工具的 agent 建议把有效预算控制在 64K–128K。 4. **Ollama 必须设置 `num_ctx`**:Ollama 的 `/v1` 兼容端点会忽略请求里的 `num_ctx`,并按显存选择默认窗口(约 4K/32K/256K);超长时从开头截断,系统提示词和工具定义会先丢失。请用 `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` 或 Modelfile `PARAMETER num_ctx 65536`,重启后用 `ollama ps` 确认 `CONTEXT`。 5. **KV cache 与前缀缓存**: - - llama.cpp:`--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`;`--cache-prompt` 默认开启。`--parallel` 会让每个槽位分摊上下文。 + - llama.cpp:`--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`;`--cache-prompt` 默认开启。`--parallel` 会让每个槽位分摊上下文;检测结果会返回 `context_slots` 并在大于 1 时提示,请按实际 KV 模式(如 `--kv-unified`)确认单请求可用窗口。 - vLLM:`--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`;用 `--max-num-seqs` 控制并发 KV 占用。 - Ollama:`OLLAMA_KV_CACHE_TYPE=q8_0`、`OLLAMA_NUM_PARALLEL=1`;窗口越大 KV cache 越大。 - LM Studio:在模型设置中显式设置 Context Length,并开启可用的 KV cache 量化。 6. **思考模型**:Qwen3.5 等默认开启 thinking,会在工具循环中消耗大量上下文。可通过通道的 `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` 关闭(vLLM、llama.cpp `--jinja` 支持);`reasoning.mode: off` 只清理 OpenAI/DeepSeek 推理字段,不一定能关闭本地 chat template 的 thinking。 7. **建议窗口下限**:带 90+ 工具的 agent 在 16K 以下很难稳定运行;32K 需要依赖 `tool_search` 和 `context_profile: local`;64K 以上更接近云端模型的使用体验。 +8. **故障转移通道不会重新预算**:`multi_agent.eino_middleware.model_failover_channels` 只切换模型端点,预算仍按主通道计算;请让 failover 通道的可用窗口不小于主通道,否则可能依赖超窗压缩/重试。 对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。 diff --git a/internal/config/config.go b/internal/config/config.go index 5e2ffc02f..435aedec2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -999,8 +999,8 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int { if out <= 0 { out = DefaultMaxCompletionTokens } - if c.ContextWindow > 0 && out > c.ContextWindow/2 { - out = c.ContextWindow / 2 + if window := c.EffectiveContextWindow(); window > 0 && out > window/2 { + out = window / 2 } return out } diff --git a/internal/config/context_profile.go b/internal/config/context_profile.go index 5e5a400e9..12aaf478c 100644 --- a/internal/config/context_profile.go +++ b/internal/config/context_profile.go @@ -17,9 +17,10 @@ const ( // 本地 tokenizer 与 tiktoken 不一致(CJK 尤甚),留出余量避免踩到运行时窗口。 LocalTokenEstimateSafetyPercent = 110 - ContextWarningLocalWindowUnknown = "local_context_window_unknown" - ContextWarningLocalWindowSmall = "local_context_window_small" - ContextWarningMaxTotalClamped = "max_total_tokens_clamped" + ContextWarningLocalWindowUnknown = "local_context_window_unknown" + ContextWarningLocalWindowSmall = "local_context_window_small" + ContextWarningMaxTotalClamped = "max_total_tokens_clamped" + ContextWarningMaxCompletionClamped = "max_completion_tokens_clamped" ) // NormalizeContextProfile 只接受 auto(空)与 local;未知值按 auto 处理,避免配置拼写错误放大预算。 @@ -107,6 +108,9 @@ func (c OpenAIConfig) EffectiveMaxTotalTokens() int { if budget < 1024 { budget = 1024 } + if window > 0 && budget > window { + budget = window + } return budget } @@ -127,6 +131,9 @@ func (c OpenAIConfig) ContextBudgetWarnings() []string { if c.EffectiveContextWindow() > 0 && configured > c.EffectiveMaxTotalTokens() { out = append(out, ContextWarningMaxTotalClamped) } + if c.EffectiveContextWindow() > 0 && c.MaxCompletionTokens > c.MaxCompletionTokensEffective() { + out = append(out, ContextWarningMaxCompletionClamped) + } return out } diff --git a/internal/config/context_profile_test.go b/internal/config/context_profile_test.go index 23c288600..ace780c1d 100644 --- a/internal/config/context_profile_test.go +++ b/internal/config/context_profile_test.go @@ -5,6 +5,16 @@ import ( "testing" ) +func TestEffectiveMaxTotalTokensNeverExceedsWindow(t *testing.T) { + t.Parallel() + for _, window := range []int{512, 1024, 2048, 4096, 8192} { + oa := OpenAIConfig{ContextWindow: window, ContextProfile: "local", MaxTotalTokens: 120000} + if got := oa.EffectiveMaxTotalTokens(); got > window { + t.Fatalf("window %d: effective budget %d exceeds window", window, got) + } + } +} + func TestTokenEstimateSafetyPercent(t *testing.T) { t.Parallel() if got := (OpenAIConfig{}).TokenEstimateSafetyPercent(); got != 100 { @@ -129,6 +139,7 @@ func TestMaxCompletionTokensEffectiveClampsToWindow(t *testing.T) { {name: "no window uses configured", oa: OpenAIConfig{MaxCompletionTokens: 32768}, want: 32768}, {name: "no window uses default", oa: OpenAIConfig{}, want: DefaultMaxCompletionTokens}, {name: "32k window halves output", oa: OpenAIConfig{ContextWindow: 32768, MaxCompletionTokens: 32768}, want: 16384}, + {name: "local missing window assumes 32k", oa: OpenAIConfig{ContextProfile: "local", MaxCompletionTokens: 32768}, want: 16384}, {name: "128k window keeps output", oa: OpenAIConfig{ContextWindow: 131072, MaxCompletionTokens: 16384}, want: 16384}, } for _, tc := range cases { @@ -168,6 +179,11 @@ func TestContextBudgetWarnings(t *testing.T) { oa: OpenAIConfig{ContextWindow: 32768, MaxTotalTokens: 64000}, want: []string{ContextWarningMaxTotalClamped}, }, + { + name: "oversized output cap warns", + oa: OpenAIConfig{ContextWindow: 32768, MaxCompletionTokens: 32768}, + want: []string{ContextWarningMaxTotalClamped, ContextWarningMaxCompletionClamped}, + }, } for _, tc := range cases { tc := tc diff --git a/internal/handler/config.go b/internal/handler/config.go index de44273ac..e0fcd78fc 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1277,26 +1277,28 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { // TestOpenAIRequest 测试OpenAI连接请求 type TestOpenAIRequest struct { - CredentialScope string `json:"credential_scope,omitempty"` - ChannelID string `json:"channel_id,omitempty"` - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model"` - MaxTotalTokens int `json:"max_total_tokens,omitempty"` - ContextProfile string `json:"context_profile,omitempty"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` } // DetectContextRequest probes model metadata without issuing a completion. type DetectContextRequest struct { - CredentialScope string `json:"credential_scope,omitempty"` - ChannelID string `json:"channel_id,omitempty"` - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model"` - MaxTotalTokens int `json:"max_total_tokens,omitempty"` - ContextProfile string `json:"context_profile,omitempty"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` } func probeModelContext(ctx context.Context, provider, baseURL, apiKey, model string) modelmeta.ContextInfo { @@ -1308,7 +1310,7 @@ func probeModelContext(ctx context.Context, provider, baseURL, apiKey, model str return modelmeta.DetectContext(probeCtx, baseURL, model, apiKey, nil) } -func addModelContextToResponse(resp gin.H, info modelmeta.ContextInfo, maxTotalTokens int, contextProfile string) { +func addModelContextToResponse(resp gin.H, info modelmeta.ContextInfo, maxTotalTokens, maxCompletionTokens int, contextProfile string) { if info.Backend != "" { resp["context_backend"] = info.Backend } @@ -1321,14 +1323,18 @@ func addModelContextToResponse(resp gin.H, info modelmeta.ContextInfo, maxTotalT if info.Loaded { resp["context_loaded"] = true } + if info.RuntimeSlots > 0 { + resp["context_slots"] = info.RuntimeSlots + } if info.RuntimeUnknown { resp["context_runtime_unknown"] = true } profile := config.NormalizeContextProfile(contextProfile) oa := config.OpenAIConfig{ - ContextWindow: info.ContextWindow, - ContextProfile: profile, - MaxTotalTokens: maxTotalTokens, + ContextWindow: info.ContextWindow, + ContextProfile: profile, + MaxTotalTokens: maxTotalTokens, + MaxCompletionTokens: maxCompletionTokens, } if info.ContextWindow > 0 || profile != "" { resp["effective_max_total_tokens"] = oa.EffectiveMaxTotalTokens() @@ -1391,7 +1397,7 @@ func (h *ConfigHandler) DetectContext(c *gin.Context) { } info := probeModelContext(c.Request.Context(), req.Provider, baseURL, req.APIKey, req.Model) resp := gin.H{"success": true, "model": req.Model} - addModelContextToResponse(resp, info, req.MaxTotalTokens, req.ContextProfile) + addModelContextToResponse(resp, info, req.MaxTotalTokens, req.MaxCompletionTokens, req.ContextProfile) c.JSON(http.StatusOK, resp) } @@ -1523,7 +1529,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { "model": chatResp.Model, "latency_ms": latency.Milliseconds(), } - addModelContextToResponse(resp, probeModelContext(c.Request.Context(), req.Provider, baseURL, req.APIKey, req.Model), req.MaxTotalTokens, req.ContextProfile) + addModelContextToResponse(resp, probeModelContext(c.Request.Context(), req.Provider, baseURL, req.APIKey, req.Model), req.MaxTotalTokens, req.MaxCompletionTokens, req.ContextProfile) c.JSON(http.StatusOK, resp) } diff --git a/internal/handler/config_context_test.go b/internal/handler/config_context_test.go index 5421306f5..615412e33 100644 --- a/internal/handler/config_context_test.go +++ b/internal/handler/config_context_test.go @@ -16,11 +16,15 @@ func TestAddModelContextToResponseLocalProfilePreview(t *testing.T) { ContextWindow: 131072, Source: "ollama_ps", Loaded: true, - }, 120000, "local") + RuntimeSlots: 2, + }, 120000, 0, "local") if got := resp["context_window"]; got != 131072 { t.Fatalf("context_window = %#v, want 131072", got) } + if got := resp["context_slots"]; got != 2 { + t.Fatalf("context_slots = %#v, want 2", got) + } if got := resp["effective_max_total_tokens"]; got != 78643 { t.Fatalf("effective_max_total_tokens = %#v, want 78643", got) } @@ -39,7 +43,7 @@ func TestAddModelContextToResponseLocalProfilePreview(t *testing.T) { func TestAddModelContextToResponseLocalUnknownWindowAssumes32K(t *testing.T) { t.Parallel() resp := gin.H{} - addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, "local") + addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, 0, "local") if got := resp["effective_max_total_tokens"]; got != 16384 { t.Fatalf("effective_max_total_tokens = %#v, want 16384", got) } @@ -49,10 +53,19 @@ func TestAddModelContextToResponseLocalUnknownWindowAssumes32K(t *testing.T) { } } +func TestAddModelContextToResponseHonorsOutputCap(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{ContextWindow: 32768}, 120000, 4096, "local") + if got := resp["effective_max_total_tokens"]; got != 19660 { + t.Fatalf("effective_max_total_tokens = %#v, want 19660", got) + } +} + func TestAddModelContextToResponseAutoProfileKeepsNoPreviewWithoutWindow(t *testing.T) { t.Parallel() resp := gin.H{} - addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, "auto") + addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, 0, "auto") if _, ok := resp["effective_max_total_tokens"]; ok { t.Fatalf("auto profile without window should not preview a budget: %#v", resp) } @@ -64,7 +77,7 @@ func TestAddModelContextToResponseDedupesWarnings(t *testing.T) { addModelContextToResponse(resp, modelmeta.ContextInfo{ ContextWindow: 32768, Warnings: []string{modelmeta.WarningContextUnknown, modelmeta.WarningContextUnknown}, - }, 64000, "auto") + }, 64000, 0, "auto") warnings := resp["context_warnings"].([]string) if countWarning(warnings, modelmeta.WarningContextUnknown) != 1 { t.Fatalf("warnings not deduped: %#v", warnings) diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 6abc4003d..b9d4e93bc 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -5033,10 +5033,13 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "type": "object", "required": []string{"api_key", "model"}, "properties": map[string]interface{}{ - "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"}, - "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认根据provider自动选择)"}, - "api_key": map[string]interface{}{"type": "string", "description": "API密钥"}, - "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "gpt-4"}, + "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"}, + "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认根据provider自动选择)"}, + "api_key": map[string]interface{}{"type": "string", "description": "API密钥"}, + "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "gpt-4"}, + "max_total_tokens": map[string]interface{}{"type": "integer", "description": "可选:当前最大上下文预算,用于计算有效预算预览"}, + "max_completion_tokens": map[string]interface{}{"type": "integer", "description": "可选:当前单次输出上限,用于计算有效预算预览"}, + "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "可选:上下文优化档位"}, }, }, }, @@ -5050,10 +5053,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "schema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "success": map[string]interface{}{"type": "boolean", "description": "是否连接成功"}, - "error": map[string]interface{}{"type": "string", "description": "失败原因(success=false时)"}, - "model": map[string]interface{}{"type": "string", "description": "实际使用的模型(success=true时)"}, - "latency_ms": map[string]interface{}{"type": "number", "description": "延迟毫秒数(success=true时)"}, + "success": map[string]interface{}{"type": "boolean", "description": "是否连接成功"}, + "error": map[string]interface{}{"type": "string", "description": "失败原因(success=false时)"}, + "model": map[string]interface{}{"type": "string", "description": "实际使用的模型(success=true时)"}, + "latency_ms": map[string]interface{}{"type": "number", "description": "延迟毫秒数(success=true时)"}, + "context_window": map[string]interface{}{"type": "integer", "description": "探测到的运行时上下文窗口(可选)"}, + "context_slots": map[string]interface{}{"type": "integer", "description": "llama.cpp 并行槽位数(可选)"}, + "effective_max_total_tokens": map[string]interface{}{"type": "integer", "description": "按档位折算后的有效预算(可选)"}, + "context_warnings": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "上下文告警码(可选)"}, }, }, }, @@ -5078,14 +5085,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "type": "object", "required": []string{"model"}, "properties": map[string]interface{}{ - "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai_compatible/claude等)", "example": "openai_compatible"}, - "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认 OpenAI)", "example": "http://127.0.0.1:11434/v1"}, - "api_key": map[string]interface{}{"type": "string", "description": "API密钥(可选,可回退到已保存通道)"}, - "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "qwen3:8b"}, - "channel_id": map[string]interface{}{"type": "string", "description": "已保存的 AI 通道 ID(用于复用密钥)"}, - "credential_scope": map[string]interface{}{"type": "string", "description": "凭据作用域(可选)"}, - "max_total_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的最大上下文预算,用于计算有效预算与告警"}, - "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "上下文优化档位"}, + "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai_compatible/claude等)", "example": "openai_compatible"}, + "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认 OpenAI)", "example": "http://127.0.0.1:11434/v1"}, + "api_key": map[string]interface{}{"type": "string", "description": "API密钥(可选,可回退到已保存通道)"}, + "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "qwen3:8b"}, + "channel_id": map[string]interface{}{"type": "string", "description": "已保存的 AI 通道 ID(用于复用密钥)"}, + "credential_scope": map[string]interface{}{"type": "string", "description": "凭据作用域(可选)"}, + "max_total_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的最大上下文预算,用于计算有效预算与告警"}, + "max_completion_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的单次输出上限,用于计算有效预算与输出预留"}, + "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "上下文优化档位"}, }, }, }, @@ -5105,6 +5113,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "context_window": map[string]interface{}{"type": "integer", "description": "运行时上下文窗口(token)"}, "context_source": map[string]interface{}{"type": "string", "description": "窗口来源(如 ollama_ps、llamacpp_props)"}, "context_loaded": map[string]interface{}{"type": "boolean", "description": "窗口是否来自已加载实例"}, + "context_slots": map[string]interface{}{"type": "integer", "description": "llama.cpp 并行槽位数(--parallel);大于 1 时单请求窗口可能小于 n_ctx"}, "context_runtime_unknown": map[string]interface{}{"type": "boolean", "description": "只能确认模型上限,无法确认运行时窗口"}, "effective_max_total_tokens": map[string]interface{}{"type": "integer", "description": "按 context_profile 折算后的有效预算"}, "context_warnings": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "告警码列表(如 ollama_v1_ignores_num_ctx、max_total_tokens_clamped)"}, diff --git a/internal/modelmeta/context.go b/internal/modelmeta/context.go index 064d01f8d..b69b14691 100644 --- a/internal/modelmeta/context.go +++ b/internal/modelmeta/context.go @@ -22,6 +22,7 @@ const ( WarningOllamaV1IgnoresNumCtx = "ollama_v1_ignores_num_ctx" WarningOllamaRuntimeUnknown = "ollama_runtime_context_unknown" WarningRuntimeUnknown = "context_runtime_unknown" + WarningLlamaCppParallelSlots = "llamacpp_parallel_slots" WarningContextUnknown = "context_window_unknown" WarningBudgetExceedsWindow = "configured_budget_exceeds_context_window" probeTimeout = 2 * time.Second @@ -32,6 +33,7 @@ type ContextInfo struct { ContextWindow int `json:"context_window,omitempty"` Source string `json:"context_source,omitempty"` Loaded bool `json:"context_loaded,omitempty"` + RuntimeSlots int `json:"context_slots,omitempty"` RuntimeUnknown bool `json:"context_runtime_unknown,omitempty"` Warnings []string `json:"context_warnings,omitempty"` } @@ -237,6 +239,14 @@ func detectLlamaCpp(ctx context.Context, client *http.Client, root, model, apiKe n = props.NCtx } info := ContextInfo{Backend: BackendLlamaCpp, Loaded: true} + if props.TotalSlots > 0 { + info.RuntimeSlots = props.TotalSlots + } + if props.TotalSlots > 1 { + // n_ctx is the shared pool; per-slot capacity depends on the KV mode + // (--parallel historically divides it, unified KV may not). + info.Warnings = append(info.Warnings, WarningLlamaCppParallelSlots) + } if n > 0 { info.ContextWindow = n info.Source = "llamacpp_props" diff --git a/internal/modelmeta/context_test.go b/internal/modelmeta/context_test.go index ec71792fb..20121a565 100644 --- a/internal/modelmeta/context_test.go +++ b/internal/modelmeta/context_test.go @@ -92,6 +92,48 @@ func TestDetectContextOllamaShowModelMax(t *testing.T) { } } +func TestDetectContextLlamaCppParallelSlotsWarns(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "default_generation_settings": map[string]interface{}{"n_ctx": 32768}, + "total_slots": 2, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-model", "", srv.Client()) + if info.ContextWindow != 32768 || info.RuntimeSlots != 2 { + t.Fatalf("unexpected info: %+v", info) + } + if !contains(info.Warnings, WarningLlamaCppParallelSlots) { + t.Fatalf("missing parallel-slots warning: %+v", info.Warnings) + } +} + +func TestDetectContextLlamaCppSingleSlotNoWarning(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "default_generation_settings": map[string]interface{}{"n_ctx": 32768}, + "total_slots": 1, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-model", "", srv.Client()) + if info.RuntimeSlots != 1 { + t.Fatalf("slots = %d, want 1", info.RuntimeSlots) + } + if contains(info.Warnings, WarningLlamaCppParallelSlots) { + t.Fatalf("unexpected parallel-slots warning: %+v", info.Warnings) + } +} + func TestDetectContextLlamaCpp(t *testing.T) { t.Parallel() mux := http.NewServeMux() diff --git a/internal/multiagent/context_budget_test.go b/internal/multiagent/context_budget_test.go index c285e398c..bcebe5251 100644 --- a/internal/multiagent/context_budget_test.go +++ b/internal/multiagent/context_budget_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "sync/atomic" "testing" "cyberstrike-ai/internal/config" @@ -16,6 +17,8 @@ import ( "go.uber.org/zap/zaptest/observer" ) +var budgetWarningTestSeq atomic.Int64 + func TestIsEinoContextOverflowError(t *testing.T) { t.Parallel() cases := []struct { @@ -96,8 +99,11 @@ func TestLogContextBudgetWarningsKeepsConfiguredValue(t *testing.T) { t.Parallel() core, logs := observer.New(zapcore.WarnLevel) logger := zap.New(core) + // The warning dedupe map is process-global; use a unique window so + // -count>1 keeps exercising the logging path. + window := 131000 + int(budgetWarningTestSeq.Add(1)) configured := config.OpenAIConfig{ - ContextWindow: 131073, + ContextWindow: window, ContextProfile: "local", MaxTotalTokens: 120000, } @@ -113,8 +119,8 @@ func TestLogContextBudgetWarningsKeepsConfiguredValue(t *testing.T) { if got, ok := fields["configured_max_total_tokens"].(int64); !ok || got != 120000 { t.Fatalf("configured_max_total_tokens = %#v, want 120000", fields["configured_max_total_tokens"]) } - if got, ok := fields["effective_max_total_tokens"].(int64); !ok || got != 78643 { - t.Fatalf("effective_max_total_tokens = %#v, want 78643", fields["effective_max_total_tokens"]) + if got, ok := fields["effective_max_total_tokens"].(int64); !ok || got != int64(effective.MaxTotalTokens) { + t.Fatalf("effective_max_total_tokens = %#v, want %d", fields["effective_max_total_tokens"], effective.MaxTotalTokens) } warnings := warningCodes(fields["warnings"]) if !hasWarningCode(warnings, config.ContextWarningMaxTotalClamped) { diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 2dbd473ba..739041867 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3107,10 +3107,12 @@ "contextWarnWindowUnknown": "No window set for the local profile; assuming 32768", "contextWarnWindowSmall": "Window below 16384; tool-using agents may be unreliable", "contextWarnBudgetClamped": "Max context budget was clamped to the model window", + "contextWarnCompletionClamped": "Max output tokens were clamped to the model window", "contextWarnBudgetExceedsWindow": "Configured budget exceeds the model window; it will be clamped at runtime", "contextWarnOllamaV1": "Ollama's /v1 endpoint ignores num_ctx; set OLLAMA_CONTEXT_LENGTH or a Modelfile parameter", "contextWarnOllamaRuntime": "Ollama may cap the window below the model maximum based on VRAM; check ollama ps after loading", "contextWarnRuntimeUnknown": "Runtime window could not be confirmed and may be below the model maximum", + "contextWarnLlamaCppSlots": "llama.cpp is running multiple parallel slots; the per-request window may be smaller than n_ctx. Check --parallel and the KV mode", "contextWarnUnknown": "No context window detected; enter it manually", "openaiReasoningTitle": "Reasoning settings", "openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 9c186f4a5..bc81752e4 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3120,10 +3120,12 @@ "contextWarnWindowUnknown": "Для локального профиля не задано окно; используется 32768", "contextWarnWindowSmall": "Окно меньше 16384; агент с инструментами может работать нестабильно", "contextWarnBudgetClamped": "Бюджет контекста ограничен размером окна модели", + "contextWarnCompletionClamped": "Максимум выходных токенов ограничен размером окна модели", "contextWarnBudgetExceedsWindow": "Бюджет превышает окно модели и будет ограничен во время работы", "contextWarnOllamaV1": "Эндпоинт Ollama /v1 игнорирует num_ctx; задайте OLLAMA_CONTEXT_LENGTH или параметр Modelfile", "contextWarnOllamaRuntime": "Ollama может уменьшить окно ниже максимума модели в зависимости от VRAM; проверьте ollama ps", "contextWarnRuntimeUnknown": "Не удалось подтвердить рабочее окно; оно может быть меньше максимума модели", + "contextWarnLlamaCppSlots": "llama.cpp запущен с несколькими параллельными слотами; окно одного запроса может быть меньше n_ctx. Проверьте --parallel и режим KV", "contextWarnUnknown": "Окно контекста не обнаружено; укажите его вручную", "openaiReasoningTitle": "Настройки рассуждения", "openaiReasoningHint": "Настройки рассуждения этого канала ИИ по умолчанию; настройки сессии чата могут их переопределить.", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 40c082827..650998c80 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3095,10 +3095,12 @@ "contextWarnWindowUnknown": "本地档位未填写窗口,按 32768 估算", "contextWarnWindowSmall": "窗口小于 16384,带工具的 agent 可能无法稳定运行", "contextWarnBudgetClamped": "最大上下文预算已按模型窗口收紧", + "contextWarnCompletionClamped": "最大输出 Token 数已按模型窗口收紧", "contextWarnBudgetExceedsWindow": "填写的预算超过模型窗口,运行时会自动收紧", "contextWarnOllamaV1": "Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile", "contextWarnOllamaRuntime": "Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps", "contextWarnRuntimeUnknown": "无法确认运行时窗口,可能小于模型上限", + "contextWarnLlamaCppSlots": "llama.cpp 配置了多个并行槽位,单请求可用窗口可能小于 n_ctx;请按 --parallel/KV 模式确认", "contextWarnUnknown": "未检测到上下文窗口,请手动填写", "openaiReasoningTitle": "推理设置", "openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。", diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 0bfcd0e8f..a42b8fa4f 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -3238,6 +3238,7 @@ async function probeSelectedAIChannels() { api_key: ch.api_key || '', model: ch.model || '', max_total_tokens: parseInt(ch.max_total_tokens, 10) || 0, + max_completion_tokens: parseInt(ch.max_completion_tokens, 10) || 0, context_profile: ch.context_profile || '' }) }); @@ -3900,10 +3901,12 @@ function contextWarningText(code) { local_context_window_unknown: settingsT('settingsBasic.contextWarnWindowUnknown', '本地档位未填写窗口,按 32768 估算'), local_context_window_small: settingsT('settingsBasic.contextWarnWindowSmall', '窗口小于 16384,带工具的 agent 可能无法稳定运行'), max_total_tokens_clamped: settingsT('settingsBasic.contextWarnBudgetClamped', '最大上下文预算已按模型窗口收紧'), + max_completion_tokens_clamped: settingsT('settingsBasic.contextWarnCompletionClamped', '最大输出 Token 数已按模型窗口收紧'), configured_budget_exceeds_context_window: settingsT('settingsBasic.contextWarnBudgetExceedsWindow', '填写的预算超过模型窗口,运行时会自动收紧'), ollama_v1_ignores_num_ctx: settingsT('settingsBasic.contextWarnOllamaV1', 'Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile'), ollama_runtime_context_unknown: settingsT('settingsBasic.contextWarnOllamaRuntime', 'Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps'), context_runtime_unknown: settingsT('settingsBasic.contextWarnRuntimeUnknown', '无法确认运行时窗口,可能小于模型上限'), + llamacpp_parallel_slots: settingsT('settingsBasic.contextWarnLlamaCppSlots', 'llama.cpp 配置了多个并行槽位,单请求可用窗口可能小于 n_ctx;请按 --parallel/KV 模式确认'), context_window_unknown: settingsT('settingsBasic.contextWarnUnknown', '未检测到上下文窗口,请手动填写') }; return messages[code] || code; @@ -3923,10 +3926,12 @@ function formatDetectedContext(result) { return parts.join(' · '); } -function applyDetectedContext(result) { +function applyDetectedContext(result, force) { if (!result || !result.context_window) return; const input = document.getElementById('openai-context-window'); - if (input && !String(input.value || '').trim()) { + // Explicit auto-detect overwrites the field so the effective-budget preview + // matches what will be saved; a connection test only fills an empty field. + if (input && (force || !String(input.value || '').trim())) { input.value = String(result.context_window); } const resultEl = document.getElementById('detect-context-result'); @@ -3970,16 +3975,13 @@ async function detectAIContext() { api_key: apiKey, model: model, max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 0, + max_completion_tokens: parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 0, context_profile: document.getElementById('openai-context-profile')?.value || '' }) }); const result = await response.json().catch(() => ({})); if (result.success && result.context_window) { - applyDetectedContext(result); - if (resultEl) { - resultEl.textContent = formatDetectedContext(result); - resultEl.className = 'form-inline-result is-success'; - } + applyDetectedContext(result, true); } else { const message = result.error || contextWarningText('context_window_unknown'); if (resultEl) { @@ -4043,6 +4045,7 @@ async function testOpenAIConnection() { api_key: apiKey, model: model, max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 0, + max_completion_tokens: parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 0, context_profile: document.getElementById('openai-context-profile')?.value || '' }) });