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 @@