diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2820daf..1f1b29f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- 本地大模型上下文优化:AI 通道新增 `context_window` 与 `context_profile: local`,支持从 Ollama / llama.cpp / LM Studio / vLLM 自动探测运行时窗口,按窗口收紧压缩预算与工具输出阈值,并让内置工具进入 `tool_search` 动态池;local 档位自动改用 `max_tokens` 兼容本地端点。 +- 本地上下文增强:检测结果展示按档位折算后的有效预算,探测支持反向代理子路径;local 档位为 token 估算保留 10% 余量,补充 llama.cpp / LM Studio / vLLM 的超窗报错识别并触发激进压缩重试;攻击链、FOFA、工作流、HITL 等旁路调用同样按档位切换输出字段,并合并通道级 `reasoning.extra_request_fields`。 + - 启动时以统一最新 SQLite 定义自动检查全部项目管理表,缺表建表、缺字段补字段,无需维护逐字段迁移列表;保留已有数据和额外表/字段,结构补齐失败回滚并阻止启动。HITL 与通知表纳入统一初始化,历史漏洞外键迁移保留额外列、索引、触发器及依赖记录。 ### Fixed diff --git a/config.example.yaml b/config.example.yaml index a25f1882d..cad4688b9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -108,6 +108,8 @@ ai: model: qwen3-max max_total_tokens: 120000 max_completion_tokens: 32768 + context_window: 262144 # 模型原生上下文窗口(token);0/省略=未知。检测到窗口后,预算会被自动收紧 + context_profile: auto # auto=通用;local=本地模型档位(32K-256K),会收紧压缩/工具输出/常驻工具,token 估算留 10% 保守余量 # Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields reasoning: mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考) @@ -123,6 +125,24 @@ ai: # model: qwen-plus # max_total_tokens: 120000 # max_completion_tokens: 32768 + # 本地模型示例(Ollama / llama.cpp / vLLM / LM Studio 均走 OpenAI 兼容端点) + # local-qwen: + # name: Local Qwen + # provider: openai_compatible + # base_url: http://127.0.0.1:11434/v1 + # api_key: ollama + # model: qwen3.5:9b + # max_total_tokens: 65536 # 运行时会再被 context_window * 60% 收紧 + # max_completion_tokens: 8192 + # context_window: 131072 # 建议用页面「自动检测」填入实际 runtime 窗口 + # context_profile: local + # # Ollama 的 /v1 接口忽略 num_ctx:请同时设置 OLLAMA_CONTEXT_LENGTH=131072 或 Modelfile PARAMETER num_ctx; + # # 否则 Ollama 会按显存使用 4K/32K/256K 默认值,并从开头截断系统提示词。 + # reasoning: + # mode: off + # extra_request_fields: + # chat_template_kwargs: + # enable_thinking: false # Qwen3.5 等思考模型在 agent 工具循环中建议关闭思考 # 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要) vision: enabled: false # true 且 model 非空时注册 analyze_image @@ -342,6 +362,7 @@ multi_agent: tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用 tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁 tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, get_tool_execution, wait_tool_execution, cancel_tool_execution, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略) + tool_search_include_builtins: true # false:后端内置工具也进入 tool_search 动态池;context_profile=local 时自动为 false plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在 plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask// reduction_enable: true # true:大工具输出截断并落盘到本地文件(tmp/reduction/.../trunc/),上下文只留 预览;不依赖 eino_skills diff --git a/docs/en-US/configuration.md b/docs/en-US/configuration.md index d46c00871..34017e067 100644 --- a/docs/en-US/configuration.md +++ b/docs/en-US/configuration.md @@ -70,10 +70,29 @@ ai: | `name` | Display name in the Web UI; falls back to the ID. | | `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels use Eino's native Anthropic Messages API component. | | `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. | -| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. | -| `max_completion_tokens` | Per-response output cap; default is used when empty. | +| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. When `context_window` is known, it is clamped to the window minus the output reserve. | +| `max_completion_tokens` | Per-response output cap; default is used when empty, and it is clamped to half of the known/assumed window. | +| `context_window` | Native model context window in tokens. When set, the runtime clamps `max_total_tokens` to it; 0/omitted means unknown. | +| `context_profile` | `auto` (default) or `local`. `local` targets 32K–256K local models: the budget is 60% of the window (capped at 128K), while summary reserve, tool-output truncation/clearing, user-message and ledger caps are tightened, and `tool_search` with a small always-visible tool set is enabled. It only tightens numeric middleware settings and enables tool_search/reduction where needed. | | `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. | +### Local Model Context (32K–256K) + +A local model's effective context is usually much smaller than its advertised window, and the “model maximum” can differ from the runtime window. Recommended setup: + +1. **Confirm the runtime window**: click **Auto-detect** next to the context window field in **System Settings → Basic Settings → AI Channel Configuration**. The probe checks Ollama `/api/ps`/`/api/show`, llama.cpp `/props`, LM Studio `/api/v0/models`, then vLLM/OpenAI-compatible `/v1/models`; reverse-proxy subpaths such as `https://host/ollama/v1` are supported. The result also shows the effective budget for the current profile. Enter the server's actual value if detection fails. +2. **Enable the local profile**: set `context_profile: local`. The budget becomes 60% of the window, also bounded by “window − output reserve” (for example, a 32K window with the default 16K output cap yields a 16K budget), and the per-request output cap is clamped to half the window; compaction triggers earlier. Tool-output truncation is roughly window/6 (8–32 KB), historical tool-result clearing is roughly 35% of the budget, and always-visible tools shrink to a core set; backend builtins also join the `tool_search` dynamic pool, and the rest are unlocked on demand. Token estimates are inflated by 10% to absorb tokenizer differences between local models and tiktoken. The profile also switches the output cap to the more widely accepted `max_tokens` field, because many local servers do not recognize `max_completion_tokens`; the same applies to attack-chain, FOFA query, workflow draft, and HITL audit calls. +3. **Do not plan around a nominal 1M window**: Qwen3.5-class models ship with 262,144 native context and can be extended to 1M with YaRN, but local VRAM limits and context rot degrade quality. For tool-using agents, keep the effective budget around 64K–128K. +4. **Ollama requires `num_ctx`**: Ollama's `/v1` compatibility endpoint ignores `num_ctx` in requests and picks a default window from VRAM (roughly 4K/32K/256K). Oversized prompts are truncated from the beginning, dropping the system prompt and tool definitions first. Use `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` or a Modelfile `PARAMETER num_ctx 65536`, then confirm `CONTEXT` in `ollama ps` after restart. +5. **KV cache and prefix caching**: + - llama.cpp: `--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`; `--cache-prompt` is on by default. `--parallel` splits the context across slots; detection returns `context_slots` and warns when it is greater than 1, so confirm the per-request window for your KV mode (for example `--kv-unified`). + - vLLM: `--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`; use `--max-num-seqs` to bound concurrent KV usage. + - Ollama: `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_NUM_PARALLEL=1`; larger windows consume more KV cache. + - LM Studio: set Context Length explicitly in the model settings and enable KV-cache quantization when available. +6. **Thinking models**: Qwen3.5-class models enable thinking by default, which burns context inside tool loops. Disable it per channel with `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` (supported by vLLM and llama.cpp with `--jinja`). `reasoning.mode: off` only clears OpenAI/DeepSeek reasoning fields and may not disable the local chat template's thinking. +7. **Minimum viable window**: an agent with 90+ tools is unreliable below 16K. 32K depends on `tool_search` and `context_profile: local`; 64K and above is closer to a cloud-model experience. +8. **Failover channels are not re-budgeted**: `multi_agent.eino_middleware.model_failover_channels` only switches the model endpoint; the budget still follows the primary channel. Keep each failover channel's available window at least as large as the primary, or expect overflow compaction/retry. + The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`. Common Web UI operations: diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 721228af7..8c241f8bb 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -71,10 +71,29 @@ ai: | `name` | Web UI 展示名。留空时使用通道 ID。 | | `provider` | `openai_compatible` 或 `claude`。`openai_compatible` 会在运行时映射为 `openai`;`claude` 使用 Eino 原生 Anthropic Messages API。 | | `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 | -| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 | -| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 | +| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。已知 `context_window` 时会收紧到“窗口 − 输出预留”。 | +| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值,并会被压到已知/假定窗口的一半以内。 | +| `context_window` | 模型原生上下文窗口(token)。填写后运行时会按窗口收紧 `max_total_tokens`;0/省略表示未知。 | +| `context_profile` | `auto`(默认)或 `local`。`local` 面向 32K–256K 本地模型:预算折到窗口的 60%(上限 128K),收紧摘要预留、工具输出截断/清理、用户消息与账本上限,并启用 `tool_search` 与精简常驻工具。数值参数只收紧、不放大,同时按需启用 tool_search/reduction。 | | `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 | +### 本地模型(32K–256K)上下文要点 + +本地模型的有效上下文通常明显小于标称窗口,且不同后端的“模型上限”和“运行时窗口”可能不一致。建议: + +1. **先确认运行时窗口**:在 **系统设置 → 基本设置 → AI 通道配置 → 模型上下文窗口** 右侧点击「自动检测」。探测顺序为 Ollama `/api/ps`、`/api/show`,llama.cpp `/props`,LM Studio `/api/v0/models`,vLLM/OpenAI 兼容 `/v1/models`;同时支持反向代理子路径(如 `https://host/ollama/v1`)。检测结果会显示按当前档位折算后的有效预算。检测不到时请手动填写服务端实际配置。 +2. **启用本地档位**:把 `context_profile` 设为 `local`。预算按窗口 60% 计算,同时不超过“窗口 − 输出预留”(例如 32K 窗口、默认输出上限 16K 时预算为 16K);单次输出上限也会被压到窗口的一半以内。摘要更早触发;工具输出截断阈值约为窗口/6(8–32 KB),历史工具结果清理阈值约为预算的 35%;常驻工具收敛到核心集合,内置工具也进入 `tool_search` 动态池,其余工具按需解锁。token 估算会额外放大 10%,抵消本地 tokenizer 与 tiktoken 在中英文上的差异。该档位还会把输出上限字段切换为更通用的 `max_tokens`(Ollama/llama.cpp 等常不识别 `max_completion_tokens`),攻击链、FOFA 语法生成、工作流草稿、HITL 审计等旁路调用同样生效。 +3. **不要按标称 1M 规划**:Qwen3.5 等模型原生 262,144,可用 YaRN 扩到 1M,但本地显存与长上下文性能衰减(context rot)会让实际效果明显下降;带工具的 agent 建议把有效预算控制在 64K–128K。 +4. **Ollama 必须设置 `num_ctx`**:Ollama 的 `/v1` 兼容端点会忽略请求里的 `num_ctx`,并按显存选择默认窗口(约 4K/32K/256K);超长时从开头截断,系统提示词和工具定义会先丢失。请用 `OLLAMA_CONTEXT_LENGTH=65536 ollama serve` 或 Modelfile `PARAMETER num_ctx 65536`,重启后用 `ollama ps` 确认 `CONTEXT`。 +5. **KV cache 与前缀缓存**: + - llama.cpp:`--ctx-size 65536 --cache-type-k q8_0 --cache-type-v q8_0 -fa`;`--cache-prompt` 默认开启。`--parallel` 会让每个槽位分摊上下文;检测结果会返回 `context_slots` 并在大于 1 时提示,请按实际 KV 模式(如 `--kv-unified`)确认单请求可用窗口。 + - vLLM:`--max-model-len 65536 --enable-prefix-caching --kv-cache-dtype fp8`;用 `--max-num-seqs` 控制并发 KV 占用。 + - Ollama:`OLLAMA_KV_CACHE_TYPE=q8_0`、`OLLAMA_NUM_PARALLEL=1`;窗口越大 KV cache 越大。 + - LM Studio:在模型设置中显式设置 Context Length,并开启可用的 KV cache 量化。 +6. **思考模型**:Qwen3.5 等默认开启 thinking,会在工具循环中消耗大量上下文。可通过通道的 `reasoning.extra_request_fields.chat_template_kwargs.enable_thinking: false` 关闭(vLLM、llama.cpp `--jinja` 支持);`reasoning.mode: off` 只清理 OpenAI/DeepSeek 推理字段,不一定能关闭本地 chat template 的 thinking。 +7. **建议窗口下限**:带 90+ 工具的 agent 在 16K 以下很难稳定运行;32K 需要依赖 `tool_search` 和 `context_profile: local`;64K 以上更接近云端模型的使用体验。 +8. **故障转移通道不会重新预算**:`multi_agent.eino_middleware.model_failover_channels` 只切换模型端点,预算仍按主通道计算;请让 failover 通道的可用窗口不小于主通道,否则可能依赖超窗压缩/重试。 + 对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。 常用操作: diff --git a/internal/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..435aedec2 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 window := c.EffectiveContextWindow(); window > 0 && out > window/2 { + out = window / 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..12aaf478c --- /dev/null +++ b/internal/config/context_profile.go @@ -0,0 +1,346 @@ +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" + ContextWarningMaxCompletionClamped = "max_completion_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 + } + if window > 0 && budget > window { + budget = window + } + 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) + } + if c.EffectiveContextWindow() > 0 && c.MaxCompletionTokens > c.MaxCompletionTokensEffective() { + out = append(out, ContextWarningMaxCompletionClamped) + } + 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..ace780c1d --- /dev/null +++ b/internal/config/context_profile_test.go @@ -0,0 +1,349 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestEffectiveMaxTotalTokensNeverExceedsWindow(t *testing.T) { + t.Parallel() + for _, window := range []int{512, 1024, 2048, 4096, 8192} { + oa := OpenAIConfig{ContextWindow: window, ContextProfile: "local", MaxTotalTokens: 120000} + if got := oa.EffectiveMaxTotalTokens(); got > window { + t.Fatalf("window %d: effective budget %d exceeds window", window, got) + } + } +} + +func TestTokenEstimateSafetyPercent(t *testing.T) { + t.Parallel() + if got := (OpenAIConfig{}).TokenEstimateSafetyPercent(); got != 100 { + 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: "local missing window assumes 32k", oa: OpenAIConfig{ContextProfile: "local", MaxCompletionTokens: 32768}, want: 16384}, + {name: "128k window keeps output", oa: OpenAIConfig{ContextWindow: 131072, MaxCompletionTokens: 16384}, want: 16384}, + } + for _, tc := range cases { + 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}, + }, + { + name: "oversized output cap warns", + oa: OpenAIConfig{ContextWindow: 32768, MaxCompletionTokens: 32768}, + want: []string{ContextWarningMaxTotalClamped, ContextWarningMaxCompletionClamped}, + }, + } + 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 TestWithEffectiveContextBudgetIsIdempotent(t *testing.T) { + t.Parallel() + cfg := &Config{ + OpenAI: OpenAIConfig{ContextWindow: 131072, ContextProfile: "local", MaxTotalTokens: 120000}, + MultiAgent: MultiAgentConfig{ + EinoMiddleware: MultiAgentEinoMiddlewareConfig{ + SummarizationTriggerRatio: 0.9, + SummarizationOutputReserveTokens: 40960, + ToolSearchAlwaysVisibleTools: []string{"read_file", "webshell_exec"}, + }, + }, + } + once := cfg.WithEffectiveContextBudget() + twice := once.WithEffectiveContextBudget() + if !reflect.DeepEqual(once.OpenAI, twice.OpenAI) { + t.Fatalf("OpenAI not idempotent: %#v vs %#v", once.OpenAI, twice.OpenAI) + } + if !reflect.DeepEqual(once.MultiAgent.EinoMiddleware, twice.MultiAgent.EinoMiddleware) { + t.Fatalf("middleware not idempotent: %#v vs %#v", once.MultiAgent.EinoMiddleware, twice.MultiAgent.EinoMiddleware) + } +} + +func TestWithEffectiveContextBudgetDoesNotMutateSource(t *testing.T) { + t.Parallel() + cfg := &Config{ + 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..e0fcd78fc 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 { @@ -1262,12 +1277,128 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { // TestOpenAIRequest 测试OpenAI连接请求 type TestOpenAIRequest struct { - CredentialScope string `json:"credential_scope,omitempty"` - ChannelID string `json:"channel_id,omitempty"` - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` +} + +// DetectContextRequest probes model metadata without issuing a completion. +type DetectContextRequest struct { + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + MaxTotalTokens int `json:"max_total_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ContextProfile string `json:"context_profile,omitempty"` +} + +func probeModelContext(ctx context.Context, provider, baseURL, apiKey, model string) modelmeta.ContextInfo { + 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, maxCompletionTokens 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.RuntimeSlots > 0 { + resp["context_slots"] = info.RuntimeSlots + } + if info.RuntimeUnknown { + resp["context_runtime_unknown"] = true + } + profile := config.NormalizeContextProfile(contextProfile) + oa := config.OpenAIConfig{ + ContextWindow: info.ContextWindow, + ContextProfile: profile, + MaxTotalTokens: maxTotalTokens, + MaxCompletionTokens: maxCompletionTokens, + } + 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.MaxCompletionTokens, req.ContextProfile) + c.JSON(http.StatusOK, resp) } // TestOpenAI 测试OpenAI API连接是否可用 @@ -1302,13 +1433,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 +1524,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.MaxCompletionTokens, req.ContextProfile) + c.JSON(http.StatusOK, resp) } // TestTypeSafeRequest 测试 TypeSafe / Jev 连接。 @@ -1595,7 +1729,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 +2287,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 +2356,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..615412e33 --- /dev/null +++ b/internal/handler/config_context_test.go @@ -0,0 +1,107 @@ +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, + RuntimeSlots: 2, + }, 120000, 0, "local") + + if got := resp["context_window"]; got != 131072 { + t.Fatalf("context_window = %#v, want 131072", got) + } + if got := resp["context_slots"]; got != 2 { + t.Fatalf("context_slots = %#v, want 2", got) + } + if got := resp["effective_max_total_tokens"]; got != 78643 { + t.Fatalf("effective_max_total_tokens = %#v, want 78643", got) + } + 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, 0, "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 TestAddModelContextToResponseHonorsOutputCap(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{ContextWindow: 32768}, 120000, 4096, "local") + if got := resp["effective_max_total_tokens"]; got != 19660 { + t.Fatalf("effective_max_total_tokens = %#v, want 19660", got) + } +} + +func TestAddModelContextToResponseAutoProfileKeepsNoPreviewWithoutWindow(t *testing.T) { + t.Parallel() + resp := gin.H{} + addModelContextToResponse(resp, modelmeta.ContextInfo{}, 120000, 0, "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, 0, "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/handler/openapi.go b/internal/handler/openapi.go index 2deea5e72..b9d4e93bc 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -5033,10 +5033,13 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "type": "object", "required": []string{"api_key", "model"}, "properties": map[string]interface{}{ - "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"}, - "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认根据provider自动选择)"}, - "api_key": map[string]interface{}{"type": "string", "description": "API密钥"}, - "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "gpt-4"}, + "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"}, + "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认根据provider自动选择)"}, + "api_key": map[string]interface{}{"type": "string", "description": "API密钥"}, + "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "gpt-4"}, + "max_total_tokens": map[string]interface{}{"type": "integer", "description": "可选:当前最大上下文预算,用于计算有效预算预览"}, + "max_completion_tokens": map[string]interface{}{"type": "integer", "description": "可选:当前单次输出上限,用于计算有效预算预览"}, + "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "可选:上下文优化档位"}, }, }, }, @@ -5050,10 +5053,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "schema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "success": map[string]interface{}{"type": "boolean", "description": "是否连接成功"}, - "error": map[string]interface{}{"type": "string", "description": "失败原因(success=false时)"}, - "model": map[string]interface{}{"type": "string", "description": "实际使用的模型(success=true时)"}, - "latency_ms": map[string]interface{}{"type": "number", "description": "延迟毫秒数(success=true时)"}, + "success": map[string]interface{}{"type": "boolean", "description": "是否连接成功"}, + "error": map[string]interface{}{"type": "string", "description": "失败原因(success=false时)"}, + "model": map[string]interface{}{"type": "string", "description": "实际使用的模型(success=true时)"}, + "latency_ms": map[string]interface{}{"type": "number", "description": "延迟毫秒数(success=true时)"}, + "context_window": map[string]interface{}{"type": "integer", "description": "探测到的运行时上下文窗口(可选)"}, + "context_slots": map[string]interface{}{"type": "integer", "description": "llama.cpp 并行槽位数(可选)"}, + "effective_max_total_tokens": map[string]interface{}{"type": "integer", "description": "按档位折算后的有效预算(可选)"}, + "context_warnings": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "上下文告警码(可选)"}, + }, + }, + }, + }, + }, + "400": map[string]interface{}{"description": "参数错误"}, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, + "/api/config/detect-context": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"配置管理"}, + "summary": "探测模型运行时上下文窗口", + "description": "探测 Ollama、llama.cpp、LM Studio、vLLM/OpenAI 兼容端点的运行时上下文窗口,并按当前档位计算有效预算;不会发起推理请求。", + "operationId": "detectModelContext", + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "required": []string{"model"}, + "properties": map[string]interface{}{ + "provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai_compatible/claude等)", "example": "openai_compatible"}, + "base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选,默认 OpenAI)", "example": "http://127.0.0.1:11434/v1"}, + "api_key": map[string]interface{}{"type": "string", "description": "API密钥(可选,可回退到已保存通道)"}, + "model": map[string]interface{}{"type": "string", "description": "模型名称", "example": "qwen3:8b"}, + "channel_id": map[string]interface{}{"type": "string", "description": "已保存的 AI 通道 ID(用于复用密钥)"}, + "credential_scope": map[string]interface{}{"type": "string", "description": "凭据作用域(可选)"}, + "max_total_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的最大上下文预算,用于计算有效预算与告警"}, + "max_completion_tokens": map[string]interface{}{"type": "integer", "description": "当前配置的单次输出上限,用于计算有效预算与输出预留"}, + "context_profile": map[string]interface{}{"type": "string", "enum": []string{"auto", "local"}, "description": "上下文优化档位"}, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "探测结果", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "success": map[string]interface{}{"type": "boolean", "description": "探测是否执行成功"}, + "model": map[string]interface{}{"type": "string", "description": "被探测的模型"}, + "context_backend": map[string]interface{}{"type": "string", "description": "识别到的后端类型(ollama/llamacpp/lmstudio/openai_compatible)"}, + "context_window": map[string]interface{}{"type": "integer", "description": "运行时上下文窗口(token)"}, + "context_source": map[string]interface{}{"type": "string", "description": "窗口来源(如 ollama_ps、llamacpp_props)"}, + "context_loaded": map[string]interface{}{"type": "boolean", "description": "窗口是否来自已加载实例"}, + "context_slots": map[string]interface{}{"type": "integer", "description": "llama.cpp 并行槽位数(--parallel);大于 1 时单请求窗口可能小于 n_ctx"}, + "context_runtime_unknown": map[string]interface{}{"type": "boolean", "description": "只能确认模型上限,无法确认运行时窗口"}, + "effective_max_total_tokens": map[string]interface{}{"type": "integer", "description": "按 context_profile 折算后的有效预算"}, + "context_warnings": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "告警码列表(如 ollama_v1_ignores_num_ctx、max_total_tokens_clamped)"}, }, }, }, diff --git a/internal/modelmeta/context.go b/internal/modelmeta/context.go new file mode 100644 index 000000000..b69b14691 --- /dev/null +++ b/internal/modelmeta/context.go @@ -0,0 +1,454 @@ +// 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" + WarningLlamaCppParallelSlots = "llamacpp_parallel_slots" + 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"` + RuntimeSlots int `json:"context_slots,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 props.TotalSlots > 0 { + info.RuntimeSlots = props.TotalSlots + } + if props.TotalSlots > 1 { + // n_ctx is the shared pool; per-slot capacity depends on the KV mode + // (--parallel historically divides it, unified KV may not). + info.Warnings = append(info.Warnings, WarningLlamaCppParallelSlots) + } + if n > 0 { + info.ContextWindow = n + info.Source = "llamacpp_props" + } 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..20121a565 --- /dev/null +++ b/internal/modelmeta/context_test.go @@ -0,0 +1,311 @@ +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 TestDetectContextLlamaCppParallelSlotsWarns(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "default_generation_settings": map[string]interface{}{"n_ctx": 32768}, + "total_slots": 2, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-model", "", srv.Client()) + if info.ContextWindow != 32768 || info.RuntimeSlots != 2 { + t.Fatalf("unexpected info: %+v", info) + } + if !contains(info.Warnings, WarningLlamaCppParallelSlots) { + t.Fatalf("missing parallel-slots warning: %+v", info.Warnings) + } +} + +func TestDetectContextLlamaCppSingleSlotNoWarning(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "default_generation_settings": map[string]interface{}{"n_ctx": 32768}, + "total_slots": 1, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + info := DetectContext(context.Background(), srv.URL+"/v1", "local-model", "", srv.Client()) + if info.RuntimeSlots != 1 { + t.Fatalf("slots = %d, want 1", info.RuntimeSlots) + } + if contains(info.Warnings, WarningLlamaCppParallelSlots) { + t.Fatalf("unexpected parallel-slots warning: %+v", info.Warnings) + } +} + +func TestDetectContextLlamaCpp(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + 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..370370022 100644 --- a/internal/multiagent/context_budget.go +++ b/internal/multiagent/context_budget.go @@ -4,14 +4,48 @@ 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 + +// logContextBudgetWarnings reports the clamp applied by WithEffectiveContextBudget. +// configured must be the pre-clamp channel config so the warning set and the +// configured value stay meaningful. +func logContextBudgetWarnings(configured, effective config.OpenAIConfig, logger *zap.Logger, phase string) { + if logger == nil { + return + } + warnings := configured.ContextBudgetWarnings() + if len(warnings) == 0 { + return + } + key := fmt.Sprintf("%d|%d|%d|%s", + configured.EffectiveContextWindow(), + configured.MaxTotalTokens, + effective.MaxTotalTokens, + strings.Join(warnings, ","), + ) + if _, loaded := contextBudgetWarned.LoadOrStore(key, struct{}{}); loaded { + return + } + logger.Warn("model context budget adjusted", + zap.String("phase", phase), + zap.Int("context_window", configured.EffectiveContextWindow()), + zap.Int("configured_max_total_tokens", configured.MaxTotalTokens), + zap.Int("effective_max_total_tokens", effective.MaxTotalTokens), + 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 +71,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 +215,7 @@ func truncateRoundMessagesToTokenBudget( type compactMessagesOpts struct { maxTokens int counter summarization.TokenCounterFunc + tools []*schema.ToolInfo toolMaxBytes int spillRef string aggressive bool @@ -181,6 +223,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 +253,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 +309,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 +320,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 +336,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 +349,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 +362,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 +372,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 +385,7 @@ func aggressiveCompactMessagesForOverflow( messages []adk.Message, maxTotalTokens int, modelName string, + tokenSafetyPercent int, toolMaxBytes int, phase string, logger *zap.Logger, @@ -318,7 +403,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..bcebe5251 100644 --- a/internal/multiagent/context_budget_test.go +++ b/internal/multiagent/context_budget_test.go @@ -4,12 +4,21 @@ import ( "context" "errors" "strings" + "sync/atomic" "testing" + "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" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) +var budgetWarningTestSeq atomic.Int64 + func TestIsEinoContextOverflowError(t *testing.T) { t.Parallel() cases := []struct { @@ -20,6 +29,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 +95,155 @@ func TestBuildBudgetedSummarizationModelInputTruncatesOversizedLatestRound(t *te } } +func TestLogContextBudgetWarningsKeepsConfiguredValue(t *testing.T) { + t.Parallel() + core, logs := observer.New(zapcore.WarnLevel) + logger := zap.New(core) + // The warning dedupe map is process-global; use a unique window so + // -count>1 keeps exercising the logging path. + window := 131000 + int(budgetWarningTestSeq.Add(1)) + configured := config.OpenAIConfig{ + ContextWindow: window, + ContextProfile: "local", + MaxTotalTokens: 120000, + } + effective := configured + effective.MaxTotalTokens = configured.EffectiveMaxTotalTokens() + logContextBudgetWarnings(configured, effective, logger, "test") + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("warn entries = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + if got, ok := fields["configured_max_total_tokens"].(int64); !ok || got != 120000 { + t.Fatalf("configured_max_total_tokens = %#v, want 120000", fields["configured_max_total_tokens"]) + } + if got, ok := fields["effective_max_total_tokens"].(int64); !ok || got != int64(effective.MaxTotalTokens) { + t.Fatalf("effective_max_total_tokens = %#v, want %d", fields["effective_max_total_tokens"], effective.MaxTotalTokens) + } + warnings := warningCodes(fields["warnings"]) + if !hasWarningCode(warnings, config.ContextWarningMaxTotalClamped) { + t.Fatalf("warnings = %#v, want clamp warning", fields["warnings"]) + } +} + +func warningCodes(value interface{}) []string { + switch v := value.(type) { + case []string: + return v + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if code, ok := item.(string); ok { + out = append(out, code) + } + } + return out + default: + return nil + } +} + +func hasWarningCode(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + +func TestLocalTokenCounterSafetyMargin(t *testing.T) { + t.Parallel() + input := &summarization.TokenCounterInput{Messages: []adk.Message{ + 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..58d34c2b4 100644 --- a/internal/multiagent/eino_single_runner.go +++ b/internal/multiagent/eino_single_runner.go @@ -45,6 +45,15 @@ func RunEinoSingleChatModelAgent( if ma == nil { return nil, fmt.Errorf("eino single: multi_agent 配置为空") } + configuredOpenAI := appCfg.OpenAI + appCfg = appCfg.WithEffectiveContextBudget() + { + maCopy := *ma + maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware + ma = &maCopy + } + logContextBudgetWarnings(configuredOpenAI, 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 +143,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 +237,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..d8406ee41 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -84,6 +84,15 @@ func RunDeepAgent( return nil, fmt.Errorf("multiagent: 配置或 Agent 为空") } + configuredOpenAI := appCfg.OpenAI + appCfg = appCfg.WithEffectiveContextBudget() + if ma != nil { + maCopy := *ma + maCopy.EinoMiddleware = appCfg.MultiAgent.EinoMiddleware + ma = &maCopy + } + logContextBudgetWarnings(configuredOpenAI, appCfg.OpenAI, logger, "deep") + runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) effectiveSubs := ma.SubAgents @@ -247,6 +256,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 +436,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 +456,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 +531,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 +652,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 { 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..739041867 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3093,6 +3093,27 @@ "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", + "contextWarnCompletionClamped": "Max output tokens were clamped to the model window", + "contextWarnBudgetExceedsWindow": "Configured budget exceeds the model window; it will be clamped at runtime", + "contextWarnOllamaV1": "Ollama's /v1 endpoint ignores num_ctx; set OLLAMA_CONTEXT_LENGTH or a Modelfile parameter", + "contextWarnOllamaRuntime": "Ollama may cap the window below the model maximum based on VRAM; check ollama ps after loading", + "contextWarnRuntimeUnknown": "Runtime window could not be confirmed and may be below the model maximum", + "contextWarnLlamaCppSlots": "llama.cpp is running multiple parallel slots; the per-request window may be smaller than n_ctx. Check --parallel and the KV mode", + "contextWarnUnknown": "No context window detected; enter it manually", "openaiReasoningTitle": "Reasoning settings", "openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.", "openaiReasoningProfile": "Wire profile", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 91d84d18d..bc81752e4 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3106,6 +3106,27 @@ "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": "Бюджет контекста ограничен размером окна модели", + "contextWarnCompletionClamped": "Максимум выходных токенов ограничен размером окна модели", + "contextWarnBudgetExceedsWindow": "Бюджет превышает окно модели и будет ограничен во время работы", + "contextWarnOllamaV1": "Эндпоинт Ollama /v1 игнорирует num_ctx; задайте OLLAMA_CONTEXT_LENGTH или параметр Modelfile", + "contextWarnOllamaRuntime": "Ollama может уменьшить окно ниже максимума модели в зависимости от VRAM; проверьте ollama ps", + "contextWarnRuntimeUnknown": "Не удалось подтвердить рабочее окно; оно может быть меньше максимума модели", + "contextWarnLlamaCppSlots": "llama.cpp запущен с несколькими параллельными слотами; окно одного запроса может быть меньше n_ctx. Проверьте --parallel и режим KV", + "contextWarnUnknown": "Окно контекста не обнаружено; укажите его вручную", "openaiReasoningTitle": "Настройки рассуждения", "openaiReasoningHint": "Настройки рассуждения этого канала ИИ по умолчанию; настройки сессии чата могут их переопределить.", "openaiReasoningProfile": "Профиль передачи", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c43d1be74..650998c80 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3081,6 +3081,27 @@ "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": "最大上下文预算已按模型窗口收紧", + "contextWarnCompletionClamped": "最大输出 Token 数已按模型窗口收紧", + "contextWarnBudgetExceedsWindow": "填写的预算超过模型窗口,运行时会自动收紧", + "contextWarnOllamaV1": "Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile", + "contextWarnOllamaRuntime": "Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps", + "contextWarnRuntimeUnknown": "无法确认运行时窗口,可能小于模型上限", + "contextWarnLlamaCppSlots": "llama.cpp 配置了多个并行槽位,单请求可用窗口可能小于 n_ctx;请按 --parallel/KV 模式确认", + "contextWarnUnknown": "未检测到上下文窗口,请手动填写", "openaiReasoningTitle": "推理设置", "openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。", "openaiReasoningProfile": "线路 profile", diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 0eb2eab9b..a42b8fa4f 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,18 @@ 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, + max_completion_tokens: parseInt(ch.max_completion_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 +3309,7 @@ if (typeof window !== 'undefined') { window.copyAIChannelFromForm = copyAIChannelFromForm; window.deleteSelectedAIChannel = deleteSelectedAIChannel; window.probeSelectedAIChannels = probeSelectedAIChannels; + window.detectAIContext = detectAIContext; window.deleteCheckedAIChannels = deleteCheckedAIChannels; } @@ -3877,6 +3896,112 @@ 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', '最大上下文预算已按模型窗口收紧'), + max_completion_tokens_clamped: settingsT('settingsBasic.contextWarnCompletionClamped', '最大输出 Token 数已按模型窗口收紧'), + configured_budget_exceeds_context_window: settingsT('settingsBasic.contextWarnBudgetExceedsWindow', '填写的预算超过模型窗口,运行时会自动收紧'), + ollama_v1_ignores_num_ctx: settingsT('settingsBasic.contextWarnOllamaV1', 'Ollama 的 /v1 接口忽略 num_ctx,请在服务端设置 OLLAMA_CONTEXT_LENGTH 或 Modelfile'), + ollama_runtime_context_unknown: settingsT('settingsBasic.contextWarnOllamaRuntime', 'Ollama 可能按显存把窗口压到模型上限以下,建议启动后查看 ollama ps'), + context_runtime_unknown: settingsT('settingsBasic.contextWarnRuntimeUnknown', '无法确认运行时窗口,可能小于模型上限'), + llamacpp_parallel_slots: settingsT('settingsBasic.contextWarnLlamaCppSlots', 'llama.cpp 配置了多个并行槽位,单请求可用窗口可能小于 n_ctx;请按 --parallel/KV 模式确认'), + context_window_unknown: settingsT('settingsBasic.contextWarnUnknown', '未检测到上下文窗口,请手动填写') + }; + return messages[code] || code; +} + +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, force) { + if (!result || !result.context_window) return; + const input = document.getElementById('openai-context-window'); + // Explicit auto-detect overwrites the field so the effective-budget preview + // matches what will be saved; a connection test only fills an empty field. + if (input && (force || !String(input.value || '').trim())) { + input.value = String(result.context_window); + } + const resultEl = document.getElementById('detect-context-result'); + 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, + max_completion_tokens: parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 0, + context_profile: document.getElementById('openai-context-profile')?.value || '' + }) + }); + const result = await response.json().catch(() => ({})); + if (result.success && result.context_window) { + applyDetectedContext(result, true); + } 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 +4043,10 @@ 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, + max_completion_tokens: parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 0, + context_profile: document.getElementById('openai-context-profile')?.value || '' }) }); @@ -3927,9 +4055,11 @@ async function testOpenAIConnection() { if (result.success) { const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : ''; const modelInfo = result.model ? ` [${result.model}]` : ''; - const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency; + const contextInfo = result.context_window ? ` · ${result.context_window} tokens` : ''; + const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency + contextInfo; aiChannelProbeResults[channelId] = { status: 'ready', message }; if (isTestingSameChannel()) { + applyDetectedContext(result); setConnectionTestResult(resultEl, 'is-success', message); } } else { diff --git a/web/templates/index.html b/web/templates/index.html index a8091752d..b00c837be 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3764,6 +3764,23 @@
额度设置
单次模型回复的输出上限,默认 16384 +
+ +
+ + 自动检测 +
+ 模型原生窗口;用于校验并收紧预算。本地模型请按服务端 num_ctx/ctx-size 填写。 + +
+
+ + + 本地档位会收紧压缩触发、工具输出阈值和常驻工具数;只收紧不放大已有配置。 +