diff --git a/internal/config/schema.go b/internal/config/schema.go index 6a98964..326fb29 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -48,43 +48,43 @@ var essential = map[string]bool{ // common holds the settings people actually revisit. Everything not listed // here or above is treated as advanced. var common = map[string]bool{ - "model.temperature": true, - "model.max_tokens": true, - "model.context_window": true, - "model.reasoning_effort": true, - "model.auxiliary": true, - "agent.max_turns": true, - "agent.personality": true, - "agent.system_prompt_extra": true, - "agent.timezone": true, - "tools.approval_mode": true, - "tools.web_search.provider": true, - "tools.web_search.api_key": true, - "terminal.backend": true, - "terminal.cwd": true, - "terminal.timeout": true, - "memory.memory_enabled": true, - "memory.user_profile_enabled": true, - "rag.embed_model": true, - "rag.embed_provider": true, - "rag.rerank_mode": true, - "rag.per_user": true, - "skills.enabled": true, - "skills.auto_create": true, - "cron.enabled": true, - "cron.timezone": true, - "gateway.enabled": true, - "gateway.telegram.enabled": true, - "gateway.discord.enabled": true, - "mcp.enabled": true, - "compression.enabled": true, - "streaming.enabled": true, - "delegation.enabled": true, - "display.show_reasoning": true, - "display.tool_progress": true, - "display.max_live_reasoning_chars": true, - "logging.level": true, - "server.host": true, + "model.temperature": true, + "model.max_tokens": true, + "model.context_window": true, + "model.reasoning_effort": true, + "model.auxiliary": true, + "agent.max_turns": true, + "agent.personality": true, + "agent.system_prompt_extra": true, + "agent.timezone": true, + "tools.approval_mode": true, + "tools.web_search.provider": true, + "tools.web_search.api_key": true, + "terminal.backend": true, + "terminal.cwd": true, + "terminal.timeout": true, + "memory.memory_enabled": true, + "memory.user_profile_enabled": true, + "rag.embed_model": true, + "rag.embed_provider": true, + "rag.rerank_mode": true, + "rag.per_user": true, + "skills.enabled": true, + "skills.auto_create": true, + "cron.enabled": true, + "cron.timezone": true, + "gateway.enabled": true, + "gateway.telegram.enabled": true, + "gateway.discord.enabled": true, + "mcp.enabled": true, + "compression.enabled": true, + "streaming.enabled": true, + "delegation.enabled": true, + "display.show_reasoning": true, + "display.tool_progress": true, + "display.max_live_reasoning_chars": true, + "logging.level": true, + "server.host": true, } func tierFor(path string) string { @@ -115,34 +115,34 @@ var enums = map[string][]string{ "session_reset.mode": {"never", "idle", "daily"}, "display.theme": {"system", "light", "dark"}, "logging.level": {"debug", "info", "warn", "error"}, - "agent.reasoning_effort": {"none", "low", "medium", "high"}, - "model.reasoning_effort": {"none", "low", "medium", "high"}, "tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"}, } var help = map[string]string{ - "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", - "model.provider": "Which entry under providers to call.", - "model.auxiliary": "Cheaper model used for summarising and other background work.", - "model.context_window": "Used to decide when to compact; set it to match your model.", - "database.driver": "sqlite for a single node, postgres when you share state.", - "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", - "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", - "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", - "agent.workspace": "The only directory file tools may read or write.", - "agent.system_prompt_extra": "Appended to the system prompt on every turn.", - "tools.toolset": "Preset deciding which tools reach the model.", - "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", - "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", - "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", - "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", - "compression.threshold": "Fraction of the context window that triggers automatic compaction.", - "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", - "memory.memory_enabled": "Lets the agent store durable facts between sessions.", - "skills.auto_create": "Allows the agent to write new skills on its own.", - "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", - "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", - "display.tool_progress": "Show live tool progress lines while a tool runs.", + "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", + "model.provider": "Which entry under providers to call.", + "model.auxiliary": "Cheaper model used for summarising and other background work.", + "model.context_window": "Used to decide when to compact; set it to match your model.", + "model.reasoning_effort": "Official provider-native reasoning value for this model. Leave empty for the provider default. Custom endpoints send the selected value as reasoning_effort.", + "agent.reasoning_effort": "Fallback reasoning value when a turn does not set one. Official adapters only send values the active model accepts.", + "database.driver": "sqlite for a single node, postgres when you share state.", + "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", + "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", + "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", + "agent.workspace": "The only directory file tools may read or write.", + "agent.system_prompt_extra": "Appended to the system prompt on every turn.", + "tools.toolset": "Preset deciding which tools reach the model.", + "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", + "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", + "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", + "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", + "compression.threshold": "Fraction of the context window that triggers automatic compaction.", + "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", + "memory.memory_enabled": "Lets the agent store durable facts between sessions.", + "skills.auto_create": "Allows the agent to write new skills on its own.", + "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", + "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", + "display.tool_progress": "Show live tool progress lines while a tool runs.", "display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.", } diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index 2bf3476..f51ce7e 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -194,14 +194,7 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { } } } - switch strings.ToLower(req.ReasoningEffort) { - case "low": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 2048} - case "medium": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 8192} - case "high": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 16384} - } + mergeEncoded(body, OfficialReasoning(c.opts.Kind, c.opts.BaseURL, req.Model).Encode(req.ReasoningEffort)) if _, ok := body["thinking"]; ok { // Thinking requires headroom beyond the budget. if maxTokens < 16384 { diff --git a/internal/llm/codex.go b/internal/llm/codex.go index ba07727..1f8d6fd 100644 --- a/internal/llm/codex.go +++ b/internal/llm/codex.go @@ -42,8 +42,12 @@ func (c *codexClient) buildBody(req Request, stream bool) map[string]any { if req.Temperature > 0 { body["temperature"] = req.Temperature } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { - body["reasoning"] = map[string]any{"effort": e} + if enc := OfficialReasoning("openai", c.opts.BaseURL, req.Model).Encode(req.ReasoningEffort); !enc.Omit { + if e, ok := enc.Body["reasoning_effort"]; ok { + body["reasoning"] = map[string]any{"effort": e} + } else { + mergeEncoded(body, enc) + } } if len(req.Tools) > 0 { tools := make([]map[string]any, 0, len(req.Tools)) diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 83cecd4..700e15d 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -375,35 +375,11 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string { // use thinkingBudget token counts. includeThoughts requests thought summaries // when the endpoint exposes them (not all reverse proxies return thought text). func geminiThinkingConfig(model, effort string) map[string]any { - e := strings.ToLower(strings.TrimSpace(effort)) - if e == "" { - return nil - } - useLevel := geminiModelUsesThinkingLevel(model) - switch e { - case "none": - if useLevel { - return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": false} - } - return map[string]any{"thinkingBudget": 0} - case "low": - if useLevel { - return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true} - } - return map[string]any{"thinkingBudget": 2048, "includeThoughts": true} - case "medium": - if useLevel { - return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true} - } - return map[string]any{"thinkingBudget": 8192, "includeThoughts": true} - case "high": - if useLevel { - return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true} - } - return map[string]any{"thinkingBudget": 24576, "includeThoughts": true} - default: + enc := OfficialReasoning("gemini", "", model).Encode(effort) + if enc.Omit || len(enc.GeminiThinking) == 0 { return nil } + return enc.GeminiThinking } func geminiModelUsesThinkingLevel(model string) bool { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 4a9ffc8..87ef063 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -189,14 +189,7 @@ func (c *openAIClient) buildBody(req Request, stream bool) map[string]any { body["parallel_tool_calls"] = false } } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { - // OpenAI uses reasoning_effort; OpenRouter accepts a reasoning object. - body["reasoning_effort"] = e - if strings.Contains(c.opts.BaseURL, "openrouter.ai") { - delete(body, "reasoning_effort") - body["reasoning"] = map[string]any{"effort": e} - } - } + mergeEncoded(body, OfficialReasoning(c.opts.Kind, c.opts.BaseURL, req.Model).Encode(req.ReasoningEffort)) for k, v := range req.Extra { body[k] = v } diff --git a/internal/llm/reasoning_official.go b/internal/llm/reasoning_official.go new file mode 100644 index 0000000..b454981 --- /dev/null +++ b/internal/llm/reasoning_official.go @@ -0,0 +1,503 @@ +package llm + +import ( + "net/url" + "strings" +) + +// Official wire shapes. Custom / unknown hosts encode nothing. +const ( + wireOpenAIEffort = "openai_effort" + wireOpenRouter = "openrouter" + wireAnthropicBudget = "anthropic_budget" + wireAnthropicAdaptive = "anthropic_adaptive" + wireGeminiBudget = "gemini_budget" + wireGeminiLevel = "gemini_level" + wireDeepSeek = "deepseek" + wireZai = "zai" + wireOllama = "ollama" +) + +// OfficialReasoningCapability is the native reasoning control for one official +// provider+model. Values are the strings that provider's API accepts. +type OfficialReasoningCapability struct { + Wire string `json:"wire,omitempty"` + Values []string `json:"values,omitempty"` + // Default is the provider's documented default when the field is omitted. + Default string `json:"default,omitempty"` +} + +// AllowsOff reports whether the official API accepts a disable value. +func (c OfficialReasoningCapability) AllowsOff() bool { + for _, v := range c.Values { + if v == "none" { + return true + } + } + return false +} + +// OfficialReasoning returns the native ladder for a known official endpoint. +// Any other host is a custom endpoint: the picker offers the full official +// value list and the request carries reasoning_effort as that string. +func OfficialReasoning(kind, baseURL, model string) OfficialReasoningCapability { + kind = strings.ToLower(strings.TrimSpace(kind)) + model = strings.TrimSpace(model) + host := officialHost(baseURL) + if !isOfficialDeployment(kind, baseURL, host) { + return customReasoning() + } + + switch { + case host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai"): + return OfficialReasoningCapability{ + Wire: wireOpenRouter, + Values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + Default: "medium", + } + case host == "api.x.ai" || host == "x.ai" || kind == "xai": + return xaiReasoning(model) + case host == "api.groq.com" || strings.HasSuffix(host, ".groq.com"): + return groqReasoning(model) + case host == "api.deepseek.com" || host == "deepseek.com": + return deepseekReasoning() + case host == "api.z.ai" || host == "z.ai" || strings.HasSuffix(host, ".z.ai"): + return zaiReasoning(model) + case isOllamaURL(baseURL, host): + return ollamaReasoning(model) + } + + switch kind { + case "anthropic": + return anthropicReasoning(model) + case "openai", "azure", "azure-openai", "azureopenai", "codex", "responses", "openai-responses": + return openaiReasoning(model) + case "gemini": + return geminiReasoning(model) + case "opencode": + return opencodeReasoning(model) + default: + return customReasoning() + } +} + +// customReasoning is the user-facing ladder for any non-official endpoint. +// Auto (empty) still omits the field. medium is included because every +// official API that has a mid rung uses that name. +func customReasoning() OfficialReasoningCapability { + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + } +} + +func isOfficialDeployment(kind, baseURL, host string) bool { + if officialKnownHost(host) || isOllamaURL(baseURL, host) { + return true + } + if host != "" { + return false + } + switch kind { + case "anthropic", "openai", "gemini", "opencode", "xai", "codex", "responses", "openai-responses": + return true + default: + return false + } +} + +func officialKnownHost(host string) bool { + switch { + case host == "api.openai.com", host == "openai.com": + return true + case host == "api.anthropic.com", host == "anthropic.com": + return true + case host == "generativelanguage.googleapis.com", strings.HasSuffix(host, ".googleapis.com"): + return true + case host == "openrouter.ai", strings.HasSuffix(host, ".openrouter.ai"): + return true + case host == "api.x.ai", host == "x.ai": + return true + case host == "api.groq.com", strings.HasSuffix(host, ".groq.com"): + return true + case host == "api.deepseek.com", host == "deepseek.com": + return true + case host == "api.z.ai", host == "z.ai", strings.HasSuffix(host, ".z.ai"): + return true + case host == "opencode.ai", strings.HasSuffix(host, ".opencode.ai"): + return true + case strings.HasSuffix(host, ".openai.azure.com"): + return true + default: + return false + } +} + +// EncodedReasoning is the official request fragment for one chosen value. +type EncodedReasoning struct { + Omit bool + Body map[string]any + GeminiThinking map[string]any +} + +// Encode maps a user-selected official value onto the provider request. +// Empty effort is Auto: omit the field so the provider default applies. +// Values the model does not advertise are omitted rather than remapped. +func (c OfficialReasoningCapability) Encode(effort string) EncodedReasoning { + effort = strings.ToLower(strings.TrimSpace(effort)) + if c.Wire == "" || len(c.Values) == 0 { + return EncodedReasoning{Omit: true} + } + if effort == "" { + return EncodedReasoning{Omit: true} + } + if !c.allows(effort) { + return EncodedReasoning{Omit: true} + } + switch c.Wire { + case wireOpenAIEffort: + return EncodedReasoning{Body: map[string]any{"reasoning_effort": effort}} + case wireOpenRouter: + if effort == "none" { + return EncodedReasoning{Body: map[string]any{"reasoning": map[string]any{"effort": "none"}}} + } + return EncodedReasoning{Body: map[string]any{"reasoning": map[string]any{"effort": effort}}} + case wireAnthropicAdaptive: + return EncodedReasoning{Body: map[string]any{ + "thinking": map[string]any{"type": "adaptive"}, + "output_config": map[string]any{"effort": effort}, + }} + case wireAnthropicBudget: + if effort == "none" { + return EncodedReasoning{Omit: true} + } + return EncodedReasoning{Body: map[string]any{ + "thinking": map[string]any{"type": "enabled", "budget_tokens": anthropicBudget(effort)}, + }} + case wireGeminiLevel: + level := strings.ToUpper(effort) + return EncodedReasoning{GeminiThinking: map[string]any{ + "thinkingLevel": level, + "includeThoughts": effort != "minimal", + }} + case wireGeminiBudget: + if effort == "none" { + return EncodedReasoning{GeminiThinking: map[string]any{"thinkingBudget": 0}} + } + return EncodedReasoning{GeminiThinking: map[string]any{ + "thinkingBudget": geminiBudget(effort), + "includeThoughts": true, + }} + case wireDeepSeek: + if effort == "none" { + return EncodedReasoning{Body: map[string]any{"thinking": map[string]any{"type": "disabled"}}} + } + return EncodedReasoning{Body: map[string]any{ + "thinking": map[string]any{"type": "enabled"}, + "reasoning_effort": effort, + }} + case wireZai: + if effort == "none" { + return EncodedReasoning{Body: map[string]any{"thinking": map[string]any{"type": "disabled"}}} + } + return EncodedReasoning{Body: map[string]any{ + "thinking": map[string]any{"type": "enabled"}, + "reasoning_effort": effort, + }} + case wireOllama: + if effort == "none" { + return EncodedReasoning{Body: map[string]any{"think": false}} + } + return EncodedReasoning{Body: map[string]any{"think": effort}} + default: + return EncodedReasoning{Omit: true} + } +} + +func (c OfficialReasoningCapability) allows(effort string) bool { + for _, v := range c.Values { + if v == effort { + return true + } + } + return false +} + +func officialHost(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return "" + } + if !strings.Contains(baseURL, "://") { + baseURL = "https://" + baseURL + } + u, err := url.Parse(baseURL) + if err != nil { + return "" + } + return strings.ToLower(u.Hostname()) +} + +func isOllamaURL(baseURL, host string) bool { + switch host { + case "localhost", "127.0.0.1", "::1": + default: + return false + } + return strings.Contains(baseURL, ":11434") +} + +func openaiReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + switch { + case strings.Contains(m, "gpt-5-chat"): + return OfficialReasoningCapability{} + case strings.Contains(m, "gpt-5.6"), strings.Contains(m, "gpt-5.5"), + strings.Contains(m, "gpt-5.4"), strings.Contains(m, "gpt-5.3"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"none", "low", "medium", "high", "xhigh", "max"}, + Default: "medium", + } + case strings.Contains(m, "gpt-5.1"), strings.Contains(m, "gpt-5-codex"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"none", "low", "medium", "high"}, + Default: "none", + } + case strings.Contains(m, "gpt-5"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"minimal", "low", "medium", "high"}, + Default: "medium", + } + case strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high"}, + Default: "medium", + } + default: + return OfficialReasoningCapability{} + } +} + +func anthropicReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + if strings.Contains(m, "4-6") || strings.Contains(m, "4.6") || + strings.Contains(m, "4-7") || strings.Contains(m, "4.7") || + strings.Contains(m, "4-8") || strings.Contains(m, "4.8") || + strings.Contains(m, "opus-5") || strings.Contains(m, "sonnet-5") || + strings.Contains(m, "haiku-5") || strings.Contains(m, "claude-5") { + return OfficialReasoningCapability{ + Wire: wireAnthropicAdaptive, + Values: []string{"low", "medium", "high", "max"}, + Default: "high", + } + } + if strings.Contains(m, "claude") || strings.Contains(m, "opus") || + strings.Contains(m, "sonnet") || strings.Contains(m, "haiku") { + return OfficialReasoningCapability{ + Wire: wireAnthropicBudget, + Values: []string{"none", "low", "medium", "high"}, + Default: "", + } + } + return OfficialReasoningCapability{} +} + +func geminiReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(strings.TrimPrefix(model, "models/")) + switch { + case strings.Contains(m, "gemini-3.1-flash-lite-image") || strings.Contains(m, "gemini-3.1-flash-lite"): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"minimal", "high"}, + Default: "minimal", + } + case strings.Contains(m, "gemini-3.6"): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"minimal", "low", "medium", "high"}, + Default: "medium", + } + case strings.Contains(m, "gemini-3.7"): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"low", "medium", "high"}, + Default: "medium", + } + case strings.Contains(m, "gemini-3.5-flash-lite"): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"minimal", "low", "medium", "high"}, + Default: "minimal", + } + case strings.Contains(m, "gemini-3.1-pro") || strings.Contains(m, "gemini-3-pro"): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"low", "medium", "high"}, + Default: "high", + } + case strings.HasPrefix(m, "gemini-3") || strings.Contains(m, "gemini-3."): + return OfficialReasoningCapability{ + Wire: wireGeminiLevel, + Values: []string{"low", "medium", "high"}, + Default: "high", + } + case strings.Contains(m, "gemini-2.5-pro"): + return OfficialReasoningCapability{ + Wire: wireGeminiBudget, + Values: []string{"low", "medium", "high"}, + Default: "", + } + case strings.Contains(m, "gemini-2.5"): + return OfficialReasoningCapability{ + Wire: wireGeminiBudget, + Values: []string{"none", "low", "medium", "high"}, + Default: "", + } + default: + return OfficialReasoningCapability{} + } +} + +func xaiReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + switch { + case strings.Contains(m, "grok-4.6"), strings.Contains(m, "grok-4-6"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high", "xhigh"}, + Default: "high", + } + case strings.Contains(m, "4.20-multi-agent") || strings.Contains(m, "4-20-multi-agent"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high", "xhigh"}, + Default: "high", + } + case strings.Contains(m, "grok-4.5"), strings.Contains(m, "grok-4-5"), + m == "grok", strings.Contains(m, "grok-latest"), strings.Contains(m, "grok-build"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high"}, + Default: "high", + } + case strings.Contains(m, "grok-3-mini"): + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high"}, + Default: "high", + } + default: + return OfficialReasoningCapability{} + } +} + +func groqReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + if strings.Contains(m, "gpt-oss") { + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"low", "medium", "high"}, + Default: "medium", + } + } + if strings.Contains(m, "qwen") { + return OfficialReasoningCapability{ + Wire: wireOpenAIEffort, + Values: []string{"none", "default"}, + Default: "default", + } + } + return OfficialReasoningCapability{} +} + +func deepseekReasoning() OfficialReasoningCapability { + return OfficialReasoningCapability{ + Wire: wireDeepSeek, + Values: []string{"none", "low", "high", "max"}, + Default: "high", + } +} + +func zaiReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + if strings.Contains(m, "glm-5.2") || strings.Contains(m, "glm-5-2") || + strings.Contains(m, "glm-5.1") || strings.Contains(m, "glm-5.") { + return OfficialReasoningCapability{ + Wire: wireZai, + Values: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + Default: "max", + } + } + return OfficialReasoningCapability{ + Wire: wireZai, + Values: []string{"none"}, + Default: "", + } +} + +func ollamaReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + if strings.Contains(m, "gpt-oss") { + return OfficialReasoningCapability{ + Wire: wireOllama, + Values: []string{"low", "medium", "high"}, + Default: "medium", + } + } + return OfficialReasoningCapability{ + Wire: wireOllama, + Values: []string{"none", "low", "medium", "high", "max"}, + Default: "", + } +} + +func opencodeReasoning(model string) OfficialReasoningCapability { + m := strings.ToLower(model) + switch { + case strings.Contains(m, "glm"): + return zaiReasoning(m) + case strings.Contains(m, "deepseek"): + return deepseekReasoning() + default: + return OfficialReasoningCapability{} + } +} + +func anthropicBudget(effort string) int { + switch effort { + case "low": + return 2048 + case "medium": + return 8192 + case "high": + return 16384 + default: + return 8192 + } +} + +func geminiBudget(effort string) int { + switch effort { + case "low": + return 2048 + case "medium": + return 8192 + case "high": + return 24576 + default: + return 8192 + } +} + +func mergeEncoded(body map[string]any, enc EncodedReasoning) { + if enc.Omit { + return + } + for k, v := range enc.Body { + body[k] = v + } +} diff --git a/internal/llm/reasoning_official_test.go b/internal/llm/reasoning_official_test.go new file mode 100644 index 0000000..9c03ede --- /dev/null +++ b/internal/llm/reasoning_official_test.go @@ -0,0 +1,326 @@ +package llm + +import ( + "reflect" + "testing" +) + +func TestOfficialReasoningKnownFamilies(t *testing.T) { + tests := []struct { + name string + kind string + baseURL string + model string + wantVals []string + wantOff bool + wantWire string + }{ + { + name: "openai gpt-5.6", + kind: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5.6-sol", + wantVals: []string{"none", "low", "medium", "high", "xhigh", "max"}, + wantOff: true, + wantWire: wireOpenAIEffort, + }, + { + name: "openai o4-mini", + kind: "openai", + baseURL: "https://api.openai.com/v1", + model: "o4-mini", + wantVals: []string{"low", "medium", "high"}, + wantOff: false, + wantWire: wireOpenAIEffort, + }, + { + name: "openai gpt-4o has no reasoning", + kind: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-4o", + }, + { + name: "anthropic sonnet 4.6 adaptive", + kind: "anthropic", + baseURL: "https://api.anthropic.com/v1", + model: "claude-sonnet-4-6", + wantVals: []string{"low", "medium", "high", "max"}, + wantOff: false, + wantWire: wireAnthropicAdaptive, + }, + { + name: "anthropic 3.7 budget", + kind: "anthropic", + baseURL: "https://api.anthropic.com/v1", + model: "claude-3-7-sonnet-20250219", + wantVals: []string{"none", "low", "medium", "high"}, + wantOff: true, + wantWire: wireAnthropicBudget, + }, + { + name: "gemini 3.6 flash levels", + kind: "gemini", + baseURL: "https://generativelanguage.googleapis.com/v1beta", + model: "gemini-3.6-flash", + wantVals: []string{"minimal", "low", "medium", "high"}, + wantOff: false, + wantWire: wireGeminiLevel, + }, + { + name: "gemini 3.1 pro", + kind: "gemini", + baseURL: "https://generativelanguage.googleapis.com/v1beta", + model: "gemini-3.1-pro-preview", + wantVals: []string{"low", "medium", "high"}, + wantOff: false, + wantWire: wireGeminiLevel, + }, + { + name: "gemini 2.5 flash budget including off", + kind: "gemini", + baseURL: "https://generativelanguage.googleapis.com/v1beta", + model: "gemini-2.5-flash", + wantVals: []string{"none", "low", "medium", "high"}, + wantOff: true, + wantWire: wireGeminiBudget, + }, + { + name: "openrouter unified effort", + kind: "openai-compatible", + baseURL: "https://openrouter.ai/api/v1", + model: "anthropic/claude-sonnet-4.5", + wantVals: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + wantOff: true, + wantWire: wireOpenRouter, + }, + { + name: "xai grok 4.5 cannot disable", + kind: "openai-compatible", + baseURL: "https://api.x.ai/v1", + model: "grok-4.5", + wantVals: []string{"low", "medium", "high"}, + wantOff: false, + wantWire: wireOpenAIEffort, + }, + { + name: "xai grok 4.6 adds xhigh", + kind: "xai", + baseURL: "https://api.x.ai/v1", + model: "grok-4.6", + wantVals: []string{"low", "medium", "high", "xhigh"}, + wantOff: false, + wantWire: wireOpenAIEffort, + }, + { + name: "deepseek v4", + kind: "openai-compatible", + baseURL: "https://api.deepseek.com", + model: "deepseek-v4-pro", + wantVals: []string{"none", "low", "high", "max"}, + wantOff: true, + wantWire: wireDeepSeek, + }, + { + name: "zai glm-5.2", + kind: "anthropic", + baseURL: "https://api.z.ai/api/anthropic/v1", + model: "glm-5.2", + wantVals: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + wantOff: true, + wantWire: wireZai, + }, + { + name: "groq gpt-oss", + kind: "openai-compatible", + baseURL: "https://api.groq.com/openai/v1", + model: "openai/gpt-oss-120b", + wantVals: []string{"low", "medium", "high"}, + wantOff: false, + wantWire: wireOpenAIEffort, + }, + { + name: "groq qwen", + kind: "openai-compatible", + baseURL: "https://api.groq.com/openai/v1", + model: "qwen/qwen3.6-27b", + wantVals: []string{"none", "default"}, + wantOff: true, + wantWire: wireOpenAIEffort, + }, + { + name: "ollama think levels", + kind: "openai-compatible", + baseURL: "http://localhost:11434/v1", + model: "qwen3", + wantVals: []string{"none", "low", "medium", "high", "max"}, + wantOff: true, + wantWire: wireOllama, + }, + { + name: "custom endpoint gets the full official value list", + kind: "openai-compatible", + baseURL: "http://localhost:8080/v1", + model: "glm-5.2", + wantVals: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + wantOff: true, + wantWire: wireOpenAIEffort, + }, + { + name: "custom anthropic-kind host is still custom", + kind: "anthropic", + baseURL: "http://127.0.0.1:8080/antigravity/v1", + model: "claude-opus-4-6", + wantVals: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + wantOff: true, + wantWire: wireOpenAIEffort, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := OfficialReasoning(tt.kind, tt.baseURL, tt.model) + if !reflect.DeepEqual(got.Values, tt.wantVals) { + t.Fatalf("Values = %#v, want %#v", got.Values, tt.wantVals) + } + if got.AllowsOff() != tt.wantOff { + t.Fatalf("AllowsOff = %v, want %v", got.AllowsOff(), tt.wantOff) + } + if got.Wire != tt.wantWire { + t.Fatalf("Wire = %q, want %q", got.Wire, tt.wantWire) + } + }) + } +} + +func TestEncodeOfficialReasoningWire(t *testing.T) { + t.Run("anthropic adaptive uses output_config not budget", func(t *testing.T) { + cap := OfficialReasoning("anthropic", "https://api.anthropic.com/v1", "claude-opus-4-6") + enc := cap.Encode("high") + if enc.Omit { + t.Fatal("expected encode") + } + if enc.Body["thinking"] == nil { + t.Fatalf("missing thinking: %#v", enc.Body) + } + th := enc.Body["thinking"].(map[string]any) + if th["type"] != "adaptive" { + t.Fatalf("thinking.type = %v, want adaptive", th["type"]) + } + if _, ok := th["budget_tokens"]; ok { + t.Fatal("adaptive must not send budget_tokens") + } + oc := enc.Body["output_config"].(map[string]any) + if oc["effort"] != "high" { + t.Fatalf("output_config.effort = %v", oc["effort"]) + } + }) + + t.Run("anthropic legacy budget", func(t *testing.T) { + cap := OfficialReasoning("anthropic", "https://api.anthropic.com/v1", "claude-3-7-sonnet-20250219") + enc := cap.Encode("medium") + th := enc.Body["thinking"].(map[string]any) + if th["type"] != "enabled" { + t.Fatalf("type = %v", th["type"]) + } + if th["budget_tokens"] != 8192 { + t.Fatalf("budget_tokens = %v, want 8192", th["budget_tokens"]) + } + }) + + t.Run("openai effort omitted when empty", func(t *testing.T) { + cap := OfficialReasoning("openai", "https://api.openai.com/v1", "gpt-5.6") + if !cap.Encode("").Omit { + t.Fatal("empty effort must omit so the provider default applies") + } + }) + + t.Run("openai rejects unknown effort", func(t *testing.T) { + cap := OfficialReasoning("openai", "https://api.openai.com/v1", "o4-mini") + if !cap.Encode("xhigh").Omit { + t.Fatal("o4-mini does not accept xhigh") + } + }) + + t.Run("openrouter uses reasoning object", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "https://openrouter.ai/api/v1", "openai/gpt-5") + enc := cap.Encode("minimal") + if _, ok := enc.Body["reasoning_effort"]; ok { + t.Fatal("openrouter must not send reasoning_effort") + } + r := enc.Body["reasoning"].(map[string]any) + if r["effort"] != "minimal" { + t.Fatalf("reasoning.effort = %v", r["effort"]) + } + }) + + t.Run("gemini 3 level", func(t *testing.T) { + cap := OfficialReasoning("gemini", "", "gemini-3.6-flash") + enc := cap.Encode("minimal") + tc := enc.GeminiThinking + if tc["thinkingLevel"] != "MINIMAL" { + t.Fatalf("thinkingLevel = %v", tc["thinkingLevel"]) + } + if _, ok := tc["thinkingBudget"]; ok { + t.Fatal("gemini 3 must not send thinkingBudget") + } + }) + + t.Run("gemini 2.5 off is budget 0", func(t *testing.T) { + cap := OfficialReasoning("gemini", "", "gemini-2.5-flash") + enc := cap.Encode("none") + if enc.GeminiThinking["thinkingBudget"] != 0 { + t.Fatalf("off budget = %v", enc.GeminiThinking["thinkingBudget"]) + } + }) + + t.Run("deepseek none disables thinking", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "https://api.deepseek.com", "deepseek-chat") + enc := cap.Encode("none") + th := enc.Body["thinking"].(map[string]any) + if th["type"] != "disabled" { + t.Fatalf("thinking.type = %v", th["type"]) + } + }) + + t.Run("deepseek max", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "https://api.deepseek.com", "deepseek-v4-pro") + enc := cap.Encode("max") + if enc.Body["reasoning_effort"] != "max" { + t.Fatalf("reasoning_effort = %v", enc.Body["reasoning_effort"]) + } + }) + + t.Run("zai off disables thinking", func(t *testing.T) { + cap := OfficialReasoning("anthropic", "https://api.z.ai/api/anthropic/v1", "glm-5.2") + enc := cap.Encode("none") + th := enc.Body["thinking"].(map[string]any) + if th["type"] != "disabled" { + t.Fatalf("thinking.type = %v", th["type"]) + } + }) + + t.Run("grok cannot send none", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "https://api.x.ai/v1", "grok-4.5") + if !cap.Encode("none").Omit { + t.Fatal("grok-4.5 must not send none") + } + }) + + t.Run("custom endpoint sends the chosen official value", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "http://127.0.0.1:8080/v1", "glm-5.2") + enc := cap.Encode("max") + if enc.Omit { + t.Fatal("custom must send the selected value") + } + if enc.Body["reasoning_effort"] != "max" { + t.Fatalf("reasoning_effort = %v", enc.Body["reasoning_effort"]) + } + }) + + t.Run("custom auto omits the field", func(t *testing.T) { + cap := OfficialReasoning("openai-compatible", "http://127.0.0.1:8080/v1", "default") + if !cap.Encode("").Omit { + t.Fatal("Auto must send nothing") + } + }) +} diff --git a/internal/llm/types.go b/internal/llm/types.go index f36a426..f1d0d8d 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -77,7 +77,7 @@ type Request struct { TopP float64 MaxTokens int StopSequences []string - ReasoningEffort string // none|low|medium|high + ReasoningEffort string // provider-native value; empty means Auto ParallelToolCalls bool PromptCache bool Extra map[string]any @@ -113,9 +113,9 @@ func (u Usage) ContextSize() int { // Response is the final result of a completion. type Response struct { - Content string `json:"content"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ThoughtSignature is Gemini part-level metadata for the final text turn. // Agent history must copy this onto the assistant Message for multi-turn // continuity when the model is not making tool calls. @@ -153,16 +153,17 @@ type Event struct { // ModelInfo describes one model offered by a provider. type ModelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - ContextWindow int `json:"context_window"` - MaxOutput int `json:"max_output"` - InputCost float64 `json:"input_cost"` // USD per 1M tokens - OutputCost float64 `json:"output_cost"` // USD per 1M tokens - Vision bool `json:"vision"` - Tools bool `json:"tools"` - Reasoning bool `json:"reasoning"` + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + MaxOutput int `json:"max_output"` + InputCost float64 `json:"input_cost"` // USD per 1M tokens + OutputCost float64 `json:"output_cost"` // USD per 1M tokens + Vision bool `json:"vision"` + Tools bool `json:"tools"` + Reasoning bool `json:"reasoning"` + ReasoningCap *OfficialReasoningCapability `json:"reasoning_capability,omitempty"` } // Client is a provider adapter. diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index b9a789d..1532cda 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -98,8 +98,9 @@ type chatRequest struct { Role string `json:"role"` // run this turn as a specialist Model string `json:"model"` Toolset string `json:"toolset"` - // ReasoningEffort overrides the configured effort for this turn (none|low| - // medium|high). Empty falls back to agent, then model config. + // ReasoningEffort is the provider-native reasoning value for this turn. + // Empty falls back to agent, then model config. Official adapters send + // only values that model advertises. ReasoningEffort string `json:"reasoning_effort"` UserID string `json:"user_id"` // ProjectDir turns a NEW session into a project session bound to this folder. diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index cb42a2a..b4ebd9d 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -299,7 +299,9 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { errs = append(errs, provErr{Provider: t.id, Label: t.label, Error: err.Error()}) return } + p := cfg.Providers[t.id] for _, m := range list { + m = withOfficialReasoning(p.Kind, p.BaseURL, m) models = append(models, row{ModelInfo: m, Provider: t.id, ProviderLabel: t.label}) } }(t) @@ -321,6 +323,43 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { }) } +func withOfficialReasoning(kind, baseURL string, m llm.ModelInfo) llm.ModelInfo { + cap := llm.OfficialReasoning(kind, baseURL, m.ID) + if len(cap.Values) == 0 { + return m + } + m.Reasoning = true + m.ReasoningCap = &cap + return m +} + +// handleOfficialReasoningCapability returns the native reasoning ladder for +// one official provider+model. Custom endpoints return an empty values list. +func (s *Server) handleOfficialReasoningCapability(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + cfg := s.config() + provider := r.URL.Query().Get("provider") + model := r.URL.Query().Get("model") + if provider == "" && strings.Contains(model, "/") { + provider, model, _ = strings.Cut(model, "/") + } + if provider == "" { + provider = cfg.Model.Provider + } + if model == "" { + model = cfg.Model.Default + } + p := cfg.Providers[provider] + cap := llm.OfficialReasoning(p.Kind, p.BaseURL, model) + writeJSON(w, http.StatusOK, map[string]any{ + "provider": provider, + "model": model, + "reasoning_capability": cap, + }) +} + func (s *Server) handleModelSet(w http.ResponseWriter, r *http.Request) { if s.requireDashboardPassword(w, r) { return diff --git a/internal/server/routes.go b/internal/server/routes.go index 0d4ac63..03ac5b9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -90,6 +90,7 @@ func (s *Server) routes() { m.HandleFunc("GET /api/context-window", s.handleContextWindow) m.HandleFunc("GET /api/model/list", s.handleModelList) m.HandleFunc("GET /api/model/list-all", s.handleModelListAll) + m.HandleFunc("GET /api/model/reasoning-capability", s.handleOfficialReasoningCapability) m.HandleFunc("POST /api/model/set", s.handleModelSet) m.HandleFunc("POST /api/providers/{id}/key", s.handleSetProviderKey) m.HandleFunc("GET /api/providers/{id}/model-info", s.handleProviderModelInfo) diff --git a/web/src/components/chat/ReasoningPicker.tsx b/web/src/components/chat/ReasoningPicker.tsx index 0ece110..cfc58d4 100644 --- a/web/src/components/chat/ReasoningPicker.tsx +++ b/web/src/components/chat/ReasoningPicker.tsx @@ -1,36 +1,51 @@ import { useEffect, useRef, useState } from 'react' import { Brain, CaretDown, Check } from '@phosphor-icons/react' +import { get } from '@/lib/api' import { cn } from '@/lib/utils' -// Reasoning effort options. Empty value means "use the configured default" -// (agent.reasoning_effort, then model.reasoning_effort). The rest map to the -// provider's thinking budget: none disables thinking, low/medium/high raise it. -const OPTIONS: { value: string; label: string; hint: string }[] = [ - { value: '', label: 'Default', hint: 'Use the configured effort' }, - { value: 'none', label: 'Off', hint: 'No reasoning' }, - { value: 'low', label: 'Low', hint: 'Brief reasoning' }, - { value: 'medium', label: 'Medium', hint: 'Balanced reasoning' }, - { value: 'high', label: 'High', hint: 'Deep reasoning' }, -] +interface Capability { + wire?: string + values?: string[] + default?: string +} + +const LABELS: Record = { + none: { label: 'Off', hint: 'Disable reasoning' }, + default: { label: 'Default', hint: 'Provider default on' }, + minimal: { label: 'Minimal', hint: 'Least thinking' }, + low: { label: 'Low', hint: 'Light reasoning' }, + medium: { label: 'Medium', hint: 'Balanced reasoning' }, + high: { label: 'High', hint: 'Deep reasoning' }, + xhigh: { label: 'Extra high', hint: 'Highest advertised effort' }, + max: { label: 'Max', hint: 'Maximum official effort' }, +} /** - * Pick the reasoning effort for the next turn straight from the composer. The - * choice rides on the message body (reasoning_effort) and overrides the - * configured default for that turn only; it is remembered in localStorage so it - * survives a reload. Mirrors RolePicker's compact chip style. + * Official-provider reasoning control. Options come from the server catalogue + * for the active official model. Custom endpoints get no hardcoded ladder. */ export function ReasoningPicker({ value, onChange, + model, compact = false, }: { value: string onChange: (effort: string) => void + model?: string compact?: boolean }) { const [open, setOpen] = useState(false) + const [cap, setCap] = useState({}) const ref = useRef(null) + useEffect(() => { + const q = model ? `?model=${encodeURIComponent(model)}` : '' + get<{ reasoning_capability?: Capability }>(`/model/reasoning-capability${q}`) + .then((d) => setCap(d.reasoning_capability ?? {})) + .catch(() => setCap({})) + }, [model]) + useEffect(() => { if (!open) return const onClick = (e: MouseEvent) => { @@ -40,18 +55,30 @@ export function ReasoningPicker({ return () => document.removeEventListener('mousedown', onClick) }, [open]) - const current = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0] + const values = cap.values ?? [] + useEffect(() => { + if (value && values.length > 0 && !values.includes(value)) { + onChange('') + } + }, [value, values, onChange]) + + if (values.length === 0) return null - const pick = (v: string) => { - onChange(v) - setOpen(false) - } + const options = [ + { value: '', label: 'Auto', hint: cap.default ? `Provider default (${cap.default})` : 'Provider default' }, + ...values.map((v) => ({ + value: v, + label: LABELS[v]?.label ?? v, + hint: LABELS[v]?.hint ?? v, + })), + ] + const current = options.find((o) => o.value === value) ?? options[0] return (
{open ? ( -
- {OPTIONS.map((o) => ( +
+ {options.map((o) => (