3 Commits

Author SHA1 Message Date
Grail Finder
eedda0ec4b Feat (pull/18994): llama.cpp reasoning 2026-02-21 16:31:59 +03:00
Grail Finder
96ffbd5cf5 Feat: openrouter reasoning 2026-02-21 16:26:13 +03:00
Grail Finder
85b11fa9ff Chore: status line, linter complaints 2026-02-21 10:15:36 +03:00
13 changed files with 106 additions and 51 deletions

View File

@@ -15,10 +15,10 @@ import (
var httpClient = &http.Client{} var httpClient = &http.Client{}
var defaultProps = map[string]float32{ var defaultProps = map[string]float32{
"temperature": 0.8, "temperature": 0.8,
"dry_multiplier": 0.0, "dry_multiplier": 0.0,
"min_p": 0.05, "min_p": 0.05,
"n_predict": -1.0, "n_predict": -1.0,
} }
func detectAPI(api string) (isCompletion, isChat, isDeepSeek, isOpenRouter bool) { func detectAPI(api string) (isCompletion, isChat, isDeepSeek, isOpenRouter bool) {
@@ -110,8 +110,8 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
req := models.NewDSChatReq(*chatBody) req := models.NewDSChatReq(*chatBody)
return json.Marshal(req) return json.Marshal(req)
case isOpenRouter: case isOpenRouter:
// OpenRouter chat // OpenRouter chat - agents don't use reasoning by default
req := models.NewOpenRouterChatReq(*chatBody, defaultProps) req := models.NewOpenRouterChatReq(*chatBody, defaultProps, "")
return json.Marshal(req) return json.Marshal(req)
default: default:
// Assume llama.cpp chat (OpenAI format) // Assume llama.cpp chat (OpenAI format)

27
bot.go
View File

@@ -573,6 +573,9 @@ func sendMsgToLLM(body io.Reader) {
defer resp.Body.Close() defer resp.Body.Close()
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
counter := uint32(0) counter := uint32(0)
reasoningBuffer := strings.Builder{}
hasReasoning := false
reasoningSent := false
for { for {
var ( var (
answerText string answerText string
@@ -645,6 +648,12 @@ func sendMsgToLLM(body io.Reader) {
// break // break
// } // }
if chunk.Finished { if chunk.Finished {
// Send any remaining reasoning if not already sent
if hasReasoning && !reasoningSent {
reasoningText := "<think>" + reasoningBuffer.String() + "</think>"
answerText = strings.ReplaceAll(reasoningText, "\n\n", "\n")
chunkChan <- answerText
}
if chunk.Chunk != "" { if chunk.Chunk != "" {
logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter) logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter)
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
@@ -656,6 +665,20 @@ func sendMsgToLLM(body io.Reader) {
if counter == 0 { if counter == 0 {
chunk.Chunk = strings.TrimPrefix(chunk.Chunk, " ") chunk.Chunk = strings.TrimPrefix(chunk.Chunk, " ")
} }
// Handle reasoning chunks - buffer them and prepend when content starts
if chunk.Reasoning != "" && !reasoningSent {
reasoningBuffer.WriteString(chunk.Reasoning)
hasReasoning = true
}
// When we get content and have buffered reasoning, send reasoning first
if chunk.Chunk != "" && hasReasoning && !reasoningSent {
reasoningText := "<think>" + reasoningBuffer.String() + "</think>"
answerText = strings.ReplaceAll(reasoningText, "\n\n", "\n")
chunkChan <- answerText
reasoningSent = true
}
// bot sends way too many \n // bot sends way too many \n
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
// Accumulate text to check for stop strings that might span across chunks // Accumulate text to check for stop strings that might span across chunks
@@ -666,7 +689,9 @@ func sendMsgToLLM(body io.Reader) {
logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText) logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText)
streamDone <- true streamDone <- true
} }
chunkChan <- answerText if answerText != "" {
chunkChan <- answerText
}
openAIToolChan <- chunk.ToolChunk openAIToolChan <- chunk.ToolChunk
if chunk.FuncName != "" { if chunk.FuncName != "" {
lastToolCall.Name = chunk.FuncName lastToolCall.Name = chunk.FuncName

View File

@@ -50,3 +50,7 @@ CharSpecificContextEnabled = true
CharSpecificContextTag = "@" CharSpecificContextTag = "@"
AutoTurn = true AutoTurn = true
StripThinkingFromAPI = true # Strip <think> blocks from messages before sending to LLM (keeps them in chat history) StripThinkingFromAPI = true # Strip <think> blocks from messages before sending to LLM (keeps them in chat history)
# OpenRouter reasoning configuration (only applies to OpenRouter chat API)
# Valid values: xhigh, high, medium, low, minimal, none (empty or none = disabled)
# Models that support reasoning will include thinking content wrapped in <think> tags
ReasoningEffort = "medium"

View File

@@ -20,6 +20,7 @@ type Config struct {
ToolUse bool `toml:"ToolUse"` ToolUse bool `toml:"ToolUse"`
ThinkUse bool `toml:"ThinkUse"` ThinkUse bool `toml:"ThinkUse"`
StripThinkingFromAPI bool `toml:"StripThinkingFromAPI"` StripThinkingFromAPI bool `toml:"StripThinkingFromAPI"`
ReasoningEffort string `toml:"ReasoningEffort"`
AssistantRole string `toml:"AssistantRole"` AssistantRole string `toml:"AssistantRole"`
SysDir string `toml:"SysDir"` SysDir string `toml:"SysDir"`
ChunkLimit uint32 `toml:"ChunkLimit"` ChunkLimit uint32 `toml:"ChunkLimit"`

View File

@@ -354,10 +354,15 @@ func makeStatusLine() string {
} }
// Get model color based on load status for local llama.cpp models // Get model color based on load status for local llama.cpp models
modelColor := getModelColor() modelColor := getModelColor()
statusLine := fmt.Sprintf(indexLineCompletion, boolColors[botRespMode], botRespMode, activeChatName, statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], botRespMode, activeChatName,
boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp], boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona, cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona,
botPersona, boolColors[injectRole], injectRole) botPersona)
// completion endpoint
if !strings.Contains(cfg.CurrentAPI, "chat") {
roleInject := fmt.Sprintf(" | role injection [%s:-:b]%v[-:-:-] (alt+7)", boolColors[injectRole], injectRole)
statusLine += roleInject
}
return statusLine + imageInfo + shellModeInfo return statusLine + imageInfo + shellModeInfo
} }
@@ -741,7 +746,6 @@ func scanFiles(dir, filter string) []string {
const maxDepth = 3 const maxDepth = 3
const maxFiles = 50 const maxFiles = 50
var files []string var files []string
var scanRecursive func(currentDir string, currentDepth int, relPath string) var scanRecursive func(currentDir string, currentDepth int, relPath string)
scanRecursive = func(currentDir string, currentDepth int, relPath string) { scanRecursive = func(currentDir string, currentDepth int, relPath string) {
if len(files) >= maxFiles { if len(files) >= maxFiles {
@@ -750,39 +754,33 @@ func scanFiles(dir, filter string) []string {
if currentDepth > maxDepth { if currentDepth > maxDepth {
return return
} }
entries, err := os.ReadDir(currentDir) entries, err := os.ReadDir(currentDir)
if err != nil { if err != nil {
return return
} }
for _, entry := range entries { for _, entry := range entries {
if len(files) >= maxFiles { if len(files) >= maxFiles {
return return
} }
name := entry.Name() name := entry.Name()
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
continue continue
} }
fullPath := name fullPath := name
if relPath != "" { if relPath != "" {
fullPath = relPath + "/" + name fullPath = relPath + "/" + name
} }
if entry.IsDir() { if entry.IsDir() {
// Recursively scan subdirectories // Recursively scan subdirectories
scanRecursive(filepath.Join(currentDir, name), currentDepth+1, fullPath) scanRecursive(filepath.Join(currentDir, name), currentDepth+1, fullPath)
} else { continue
// Check if file matches filter }
if filter == "" || strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(filter)) { // Check if file matches filter
files = append(files, fullPath) if filter == "" || strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(filter)) {
} files = append(files, fullPath)
} }
} }
} }
scanRecursive(dir, 0, "") scanRecursive(dir, 0, "")
return files return files
} }

18
llm.go
View File

@@ -237,8 +237,10 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
return &models.TextChunk{Finished: true}, nil return &models.TextChunk{Finished: true}, nil
} }
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{ resp := &models.TextChunk{
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content, Chunk: lastChoice.Delta.Content,
Reasoning: lastChoice.Delta.ReasoningContent,
} }
// Check for tool calls in all choices, not just the last one // Check for tool calls in all choices, not just the last one
@@ -256,7 +258,7 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
} }
} }
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" { if lastChoice.FinishReason == "stop" {
if resp.Chunk != "" { if resp.Chunk != "" {
logger.Error("text inside of finish llmchunk", "chunk", llmchunk) logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
} }
@@ -614,12 +616,14 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
logger.Error("failed to decode", "error", err, "line", string(data)) logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err return nil, err
} }
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{ resp := &models.TextChunk{
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content, Chunk: lastChoice.Delta.Content,
Reasoning: lastChoice.Delta.Reasoning,
} }
// Handle tool calls similar to LCPChat // Handle tool calls similar to LCPChat
if len(llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls) > 0 { if len(lastChoice.Delta.ToolCalls) > 0 {
toolCall := llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls[0] toolCall := lastChoice.Delta.ToolCalls[0]
resp.ToolChunk = toolCall.Function.Arguments resp.ToolChunk = toolCall.Function.Arguments
fname := toolCall.Function.Name fname := toolCall.Function.Name
if fname != "" { if fname != "" {
@@ -631,7 +635,7 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
if resp.ToolChunk != "" { if resp.ToolChunk != "" {
resp.ToolResp = true resp.ToolResp = true
} }
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" { if lastChoice.FinishReason == "stop" {
if resp.Chunk != "" { if resp.Chunk != "" {
logger.Error("text inside of finish llmchunk", "chunk", llmchunk) logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
} }
@@ -710,7 +714,7 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
} }
// Clean null/empty messages to prevent API issues // Clean null/empty messages to prevent API issues
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps) orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps, cfg.ReasoningEffort)
if cfg.ToolUse && !resume && role != cfg.ToolRole { if cfg.ToolUse && !resume && role != cfg.ToolRole {
orBody.Tools = baseTools // set tools to use orBody.Tools = baseTools // set tools to use
} }

20
main.go
View File

@@ -5,16 +5,16 @@ import (
) )
var ( var (
boolColors = map[bool]string{true: "green", false: "red"} boolColors = map[bool]string{true: "green", false: "red"}
botRespMode = false botRespMode = false
editMode = false editMode = false
roleEditMode = false roleEditMode = false
injectRole = true injectRole = true
selectedIndex = int(-1) selectedIndex = int(-1)
shellMode = false shellMode = false
thinkingCollapsed = false thinkingCollapsed = false
indexLineCompletion = "F12 to show keys help | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role injection (alt+7) [%s:-:b]%v[-:-:-]" statusLineTempl = "help (F12) | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) |tool-use: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | voice recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
focusSwitcher = map[tview.Primitive]tview.Primitive{} focusSwitcher = map[tview.Primitive]tview.Primitive{}
) )
func main() { func main() {

View File

@@ -64,8 +64,9 @@ type LLMRespChunk struct {
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
Index int `json:"index"` Index int `json:"index"`
Delta struct { Delta struct {
Content string `json:"content"` Content string `json:"content"`
ToolCalls []ToolDeltaResp `json:"tool_calls"` ReasoningContent string `json:"reasoning_content"`
ToolCalls []ToolDeltaResp `json:"tool_calls"`
} `json:"delta"` } `json:"delta"`
} `json:"choices"` } `json:"choices"`
Created int `json:"created"` Created int `json:"created"`
@@ -86,6 +87,7 @@ type TextChunk struct {
ToolResp bool ToolResp bool
FuncName string FuncName string
ToolID string ToolID string
Reasoning string // For models that send reasoning separately (OpenRouter, etc.)
} }
type TextContentPart struct { type TextContentPart struct {

View File

@@ -25,17 +25,23 @@ func NewOpenRouterCompletionReq(model, prompt string, props map[string]float32,
} }
type OpenRouterChatReq struct { type OpenRouterChatReq struct {
Messages []RoleMsg `json:"messages"` Messages []RoleMsg `json:"messages"`
Model string `json:"model"` Model string `json:"model"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Temperature float32 `json:"temperature"` Temperature float32 `json:"temperature"`
MinP float32 `json:"min_p"` MinP float32 `json:"min_p"`
NPredict int32 `json:"max_tokens"` NPredict int32 `json:"max_tokens"`
Tools []Tool `json:"tools"` Tools []Tool `json:"tools"`
Reasoning *ReasoningConfig `json:"reasoning,omitempty"`
} }
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatReq { type ReasoningConfig struct {
return OpenRouterChatReq{ Effort string `json:"effort,omitempty"` // xhigh, high, medium, low, minimal, none
Summary string `json:"summary,omitempty"` // auto, concise, detailed
}
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32, reasoningEffort string) OpenRouterChatReq {
req := OpenRouterChatReq{
Messages: cb.Messages, Messages: cb.Messages,
Model: cb.Model, Model: cb.Model,
Stream: cb.Stream, Stream: cb.Stream,
@@ -43,6 +49,13 @@ func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatR
MinP: props["min_p"], MinP: props["min_p"],
NPredict: int32(props["n_predict"]), NPredict: int32(props["n_predict"]),
} }
// Only include reasoning config if effort is specified and not "none"
if reasoningEffort != "" && reasoningEffort != "none" {
req.Reasoning = &ReasoningConfig{
Effort: reasoningEffort,
}
}
return req
} }
type OpenRouterChatRespNonStream struct { type OpenRouterChatRespNonStream struct {
@@ -82,6 +95,7 @@ type OpenRouterChatResp struct {
Delta struct { Delta struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Reasoning string `json:"reasoning"`
ToolCalls []ToolDeltaResp `json:"tool_calls"` ToolCalls []ToolDeltaResp `json:"tool_calls"`
} `json:"delta"` } `json:"delta"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`

View File

@@ -388,7 +388,7 @@ func showFileCompletionPopup(filter string) {
app.SetFocus(widget) app.SetFocus(widget)
} }
func updateWidgetColors(theme tview.Theme) { func updateWidgetColors(theme *tview.Theme) {
bgColor := theme.PrimitiveBackgroundColor bgColor := theme.PrimitiveBackgroundColor
fgColor := theme.PrimaryTextColor fgColor := theme.PrimaryTextColor
borderColor := theme.BorderColor borderColor := theme.BorderColor
@@ -476,7 +476,7 @@ func showColorschemeSelectionPopup() {
tview.Styles = theme tview.Styles = theme
go func() { go func() {
app.QueueUpdateDraw(func() { app.QueueUpdateDraw(func() {
updateWidgetColors(theme) updateWidgetColors(&theme)
}) })
}() }()
} }

View File

@@ -149,6 +149,11 @@ func makePropsTable(props map[string]float32) *tview.Table {
addListPopupRow("Set log level", logLevels, GetLogLevel(), func(option string) { addListPopupRow("Set log level", logLevels, GetLogLevel(), func(option string) {
setLogLevel(option) setLogLevel(option)
}) })
// Add reasoning effort dropdown (for OpenRouter and supported APIs)
reasoningEfforts := []string{"", "none", "minimal", "low", "medium", "high", "xhigh"}
addListPopupRow("Reasoning effort (OR)", reasoningEfforts, cfg.ReasoningEffort, func(option string) {
cfg.ReasoningEffort = option
})
// Helper function to get model list for a given API // Helper function to get model list for a given API
getModelListForAPI := func(api string) []string { getModelListForAPI := func(api string) []string {
if strings.Contains(api, "api.deepseek.com/") { if strings.Contains(api, "api.deepseek.com/") {

View File

@@ -1046,6 +1046,7 @@ func makeFilePicker() *tview.Flex {
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos] actualItemName = itemText[:bracketPos]
} }
// nolint: gocritic
if strings.HasPrefix(actualItemName, "../") { if strings.HasPrefix(actualItemName, "../") {
targetDir = path.Dir(currentDisplayDir) targetDir = path.Dir(currentDisplayDir)
} else if strings.HasSuffix(actualItemName, "/") { } else if strings.HasSuffix(actualItemName, "/") {

1
tui.go
View File

@@ -835,6 +835,7 @@ func init() {
lastMsg := chatBody.Messages[len(chatBody.Messages)-1] lastMsg := chatBody.Messages[len(chatBody.Messages)-1]
cleanedText := models.CleanText(lastMsg.Content) cleanedText := models.CleanText(lastMsg.Content)
if cleanedText != "" { if cleanedText != "" {
// nolint: errcheck
go orator.Speak(cleanedText) go orator.Speak(cleanedText)
} }
} }