summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-17 22:49:13 +0300
committerPaul Buetow <paul@buetow.org>2025-09-17 22:49:13 +0300
commitd059ae333fa1c89cb58d7fb56ead79cdba15d5db (patch)
treeae65ad59c8590f71232a6abefee312b72ddf6d3e
parent88103657fb230bb41217a06aa5602ae23e7acb8b (diff)
chore(version): bump to v0.11.1 (gpt-5 defaults, timeouts, global stats, editor fix)v0.11.1
-rw-r--r--internal/hexaiaction/prompts.go13
-rw-r--r--internal/hexaicli/run.go11
-rw-r--r--internal/hexaicli/run_editor_behavior_test.go47
-rw-r--r--internal/llm/openai.go27
-rw-r--r--internal/llm/openai_request_test.go32
-rw-r--r--internal/llm/openai_temp_test.go43
-rw-r--r--internal/llm/provider.go22
-rw-r--r--internal/lsp/handlers_codeaction.go12
-rw-r--r--internal/lsp/handlers_completion.go4
-rw-r--r--internal/lsp/handlers_document.go2
-rw-r--r--internal/lsp/handlers_utils.go10
-rw-r--r--internal/lsp/llm_request_opts_test.go31
-rw-r--r--internal/version.go2
13 files changed, 225 insertions, 31 deletions
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go
index 393e9e4..207302e 100644
--- a/internal/hexaiaction/prompts.go
+++ b/internal/hexaiaction/prompts.go
@@ -152,17 +152,24 @@ func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opt
// reqOptsFrom builds LLM request options similar to LSP behavior.
func reqOptsFrom(cfg appconfig.App) []llm.RequestOption {
opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
+ // Apply temperature, with special-case for gpt-5 (default temp must be 1.0)
if cfg.CodingTemperature != nil {
- opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
+ temp := *cfg.CodingTemperature
+ prov := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel))
+ if prov == "openai" && strings.HasPrefix(model, "gpt-5") {
+ temp = 1.0
+ }
+ opts = append(opts, llm.WithTemperature(temp))
}
return opts
}
// Timeout helpers to mirror LSP behavior.
func timeout10s(parent context.Context) (context.Context, context.CancelFunc) {
- return context.WithTimeout(parent, 10*time.Second)
+ return context.WithTimeout(parent, 20*time.Second)
}
func timeout8s(parent context.Context) (context.Context, context.CancelFunc) {
- return context.WithTimeout(parent, 8*time.Second)
+ return context.WithTimeout(parent, 18*time.Second)
}
diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go
index 9909f4f..823dcaa 100644
--- a/internal/hexaicli/run.go
+++ b/internal/hexaicli/run.go
@@ -35,16 +35,15 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err)
return err
}
- // No args: open editor to capture a prompt, then combine with stdin as usual.
- if len(args) == 0 {
+ // Prefer piped stdin when present; only open the editor when there are no args
+ // and no stdin content available.
+ input, rerr := readInput(stdin, args)
+ if rerr != nil && len(args) == 0 {
if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" {
args = []string{prompt}
- } else {
- // If editor fails or empty, continue; readInput will likely error if no stdin either.
+ input, rerr = readInput(stdin, args)
}
}
- // Inline the flow here to use configured CLI prompts.
- input, rerr := readInput(stdin, args)
if rerr != nil {
fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset)
return rerr
diff --git a/internal/hexaicli/run_editor_behavior_test.go b/internal/hexaicli/run_editor_behavior_test.go
new file mode 100644
index 0000000..a934473
--- /dev/null
+++ b/internal/hexaicli/run_editor_behavior_test.go
@@ -0,0 +1,47 @@
+package hexaicli
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/editor"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// fake client that returns a fixed response
+type okClient struct{}
+
+func (okClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return "OK", nil
+}
+func (okClient) Name() string { return "prov" }
+func (okClient) DefaultModel() string { return "m" }
+
+// Ensure that when stdin has content and args are empty, Run does not open the editor.
+func TestRun_DoesNotOpenEditorWhenStdinPresent(t *testing.T) {
+ // Guard: make editor invocation fatal if called
+ oldRunEd := editor.RunEditor
+ defer func() { editor.RunEditor = oldRunEd }()
+ editor.RunEditor = func(_ string, _ string) error {
+ t.Fatalf("editor should not be invoked when stdin has content")
+ return nil
+ }
+
+ // Stub client constructor to avoid hitting real providers
+ oldNew := newClientFromApp
+ defer func() { newClientFromApp = oldNew }()
+ newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return okClient{}, nil }
+
+ var out, errb bytes.Buffer
+ restore, f := setStdin(t, "from-stdin")
+ defer restore()
+ if err := Run(context.Background(), nil, f, &out, &errb); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if !strings.Contains(out.String(), "OK") {
+ t.Fatalf("expected OK output, got %q", out.String())
+ }
+}
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index e9a1fdc..8b00335 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -26,12 +26,13 @@ type openAIClient struct {
}
type oaChatRequest struct {
- Model string `json:"model"`
- Messages []oaMessage `json:"messages"`
- Temperature *float64 `json:"temperature,omitempty"`
- MaxTokens *int `json:"max_tokens,omitempty"`
- Stop []string `json:"stop,omitempty"`
- Stream bool `json:"stream,omitempty"`
+ Model string `json:"model"`
+ Messages []oaMessage `json:"messages"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ MaxTokens *int `json:"max_tokens,omitempty"`
+ MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
+ Stop []string `json:"stop,omitempty"`
+ Stream bool `json:"stream,omitempty"`
}
type oaMessage struct {
@@ -208,7 +209,11 @@ func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, str
req.Temperature = &t
}
if o.MaxTokens > 0 {
- req.MaxTokens = &o.MaxTokens
+ if requiresMaxCompletionTokens(o.Model) {
+ req.MaxCompletionTokens = &o.MaxTokens
+ } else {
+ req.MaxTokens = &o.MaxTokens
+ }
}
if len(o.Stop) > 0 {
req.Stop = o.Stop
@@ -216,6 +221,14 @@ func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, str
return req
}
+// requiresMaxCompletionTokens reports whether the given model prefers the
+// new parameter name "max_completion_tokens" instead of "max_tokens". Newer
+// models (e.g., gpt-5 family) expect this per OpenAI's API error guidance.
+func requiresMaxCompletionTokens(model string) bool {
+ m := strings.ToLower(strings.TrimSpace(model))
+ return strings.HasPrefix(m, "gpt-5")
+}
+
func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
diff --git a/internal/llm/openai_request_test.go b/internal/llm/openai_request_test.go
new file mode 100644
index 0000000..f9925f9
--- /dev/null
+++ b/internal/llm/openai_request_test.go
@@ -0,0 +1,32 @@
+package llm
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestBuildOAChatRequest_MaxTokensKeyByModel(t *testing.T) {
+ msgs := []Message{{Role: "user", Content: "hi"}}
+ mt := 123
+ // Legacy model: use max_tokens
+ r1 := buildOAChatRequest(Options{Model: "gpt-4.1", MaxTokens: mt}, msgs, nil, false)
+ b1, _ := json.Marshal(r1)
+ if !contains(string(b1), "max_tokens") || contains(string(b1), "max_completion_tokens") {
+ t.Fatalf("expected max_tokens only, got %s", string(b1))
+ }
+ // gpt-5 family: use max_completion_tokens
+ r2 := buildOAChatRequest(Options{Model: "gpt-5.0-preview", MaxTokens: mt}, msgs, nil, false)
+ b2, _ := json.Marshal(r2)
+ if !contains(string(b2), "max_completion_tokens") || contains(string(b2), "max_tokens\":") {
+ t.Fatalf("expected max_completion_tokens only, got %s", string(b2))
+ }
+}
+
+func contains(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/llm/openai_temp_test.go b/internal/llm/openai_temp_test.go
new file mode 100644
index 0000000..7615117
--- /dev/null
+++ b/internal/llm/openai_temp_test.go
@@ -0,0 +1,43 @@
+package llm
+
+import "testing"
+
+func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) {
+ // OpenAI, gpt-5.* → default temp 1.0 when not provided
+ cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0-preview"}
+ c, err := NewFromConfig(cfg, "key", "")
+ if err != nil {
+ t.Fatalf("new: %v", err)
+ }
+ oc, ok := c.(openAIClient)
+ if !ok {
+ t.Fatalf("expected openAIClient")
+ }
+ if oc.defaultTemperature == nil || *oc.defaultTemperature != 1.0 {
+ t.Fatalf("expected default temp 1.0 for gpt-5, got %#v", oc.defaultTemperature)
+ }
+ // OpenAI, gpt-4.* → default temp 0.2 when not provided
+ cfg2 := Config{Provider: "openai", OpenAIModel: "gpt-4.1"}
+ c2, err := NewFromConfig(cfg2, "key", "")
+ if err != nil {
+ t.Fatalf("new2: %v", err)
+ }
+ oc2 := c2.(openAIClient)
+ if oc2.defaultTemperature == nil || *oc2.defaultTemperature != 0.2 {
+ t.Fatalf("expected default temp 0.2 for gpt-4.*, got %#v", oc2.defaultTemperature)
+ }
+}
+
+func TestNewFromConfig_DefaultTemp_UpgradeWhenGpt5AndDefault02(t *testing.T) {
+ // Simulate app-default of 0.2 while selecting a gpt-5 model: should upgrade to 1.0
+ v := 0.2
+ cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0", OpenAITemperature: &v}
+ c, err := NewFromConfig(cfg, "key", "")
+ if err != nil {
+ t.Fatalf("new: %v", err)
+ }
+ oc := c.(openAIClient)
+ if oc.defaultTemperature == nil || *oc.defaultTemperature != 1.0 {
+ t.Fatalf("expected upgraded default temp 1.0 for gpt-5 with default 0.2, got %#v", oc.defaultTemperature)
+ }
+}
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index 88c280c..84efaf9 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -92,10 +92,24 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro
if strings.TrimSpace(openAIAPIKey) == "" {
return nil, errors.New("missing OPENAI_API_KEY for provider openai")
}
- // Set coding-friendly default temperature if none provided
- if cfg.OpenAITemperature == nil {
- t := 0.2
- cfg.OpenAITemperature = &t
+ // Default temperature selection:
+ // - When model is gpt-5*, prefer 1.0 by default (more exploratory).
+ // - Otherwise, prefer 0.2 by default (coding friendly).
+ // The app-wide defaults currently set provider temps to 0.2.
+ // If the user hasn't explicitly overridden and the model is gpt-5*,
+ // upgrade 0.2 → 1.0 to satisfy the requested default for gpt-5.
+ model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel))
+ if strings.HasPrefix(model, "gpt-5") {
+ if cfg.OpenAITemperature == nil {
+ v := 1.0
+ cfg.OpenAITemperature = &v
+ } else if *cfg.OpenAITemperature == 0.2 {
+ v := 1.0
+ cfg.OpenAITemperature = &v
+ }
+ } else if cfg.OpenAITemperature == nil {
+ v := 0.2
+ cfg.OpenAITemperature = &v
}
return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil
case "ollama":
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 9bc3f51..e5e61ef 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -174,7 +174,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
case "rewrite":
sys := s.promptRewriteSystem
user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
@@ -199,7 +199,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
}
diagList := b.String()
user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 22*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
@@ -215,7 +215,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
case "document":
sys := s.promptDocumentSystem
user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
@@ -242,7 +242,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
sys := s.promptRewriteSystem
// Reuse rewrite user template with a fixed instruction
user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
@@ -293,7 +293,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
sys = s.promptRewriteSystem
user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection})
}
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
@@ -610,7 +610,7 @@ func (s *Server) generateGoTestFunction(funcCode string) string {
if s.llmClient != nil {
sys := s.promptGoTestSystem
user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode})
- ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index 9ef62f1..6142a30 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -72,7 +72,7 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below,
}
func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) {
- ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
inlinePrompt := lineHasInlinePrompt(current)
@@ -243,7 +243,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
prov = s.llmClient.Name()
}
logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path)
- ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second)
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel2()
// Debounce and throttle prior to provider-native call
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index 9a12948..3897885 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -154,7 +154,7 @@ func (s *Server) detectAndHandleChat(uri string) {
lineIdx := i
lastIdx := j
go func(prompt string, remove int) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
// Build messages with history and context_mode aware extras.
pos := Position{Line: lineIdx, Character: lastIdx + 1}
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index 43bfdc8..c0ec7c3 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -24,7 +24,15 @@ var (
func (s *Server) llmRequestOpts() []llm.RequestOption {
opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
if s.codingTemperature != nil {
- opts = append(opts, llm.WithTemperature(*s.codingTemperature))
+ temp := *s.codingTemperature
+ if s.llmClient != nil {
+ prov := strings.ToLower(strings.TrimSpace(s.llmClient.Name()))
+ model := strings.ToLower(strings.TrimSpace(s.llmClient.DefaultModel()))
+ if prov == "openai" && strings.HasPrefix(model, "gpt-5") {
+ temp = 1.0
+ }
+ }
+ opts = append(opts, llm.WithTemperature(temp))
}
return opts
}
diff --git a/internal/lsp/llm_request_opts_test.go b/internal/lsp/llm_request_opts_test.go
new file mode 100644
index 0000000..f4d2ef3
--- /dev/null
+++ b/internal/lsp/llm_request_opts_test.go
@@ -0,0 +1,31 @@
+package lsp
+
+import (
+ "context"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+type fakeClient struct{ name, model string }
+
+func (f fakeClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return "", nil
+}
+func (f fakeClient) Name() string { return f.name }
+func (f fakeClient) DefaultModel() string { return f.model }
+
+func TestLlmRequestOpts_Gpt5_ForcesTemp1(t *testing.T) {
+ s := newTestServer()
+ one := 0.2
+ s.codingTemperature = &one
+ s.llmClient = fakeClient{name: "openai", model: "gpt-5.0"}
+ opts := s.llmRequestOpts()
+ var got llm.Options
+ for _, o := range opts {
+ o(&got)
+ }
+ if got.Temperature != 1.0 {
+ t.Fatalf("expected temp 1.0 for gpt-5, got %v", got.Temperature)
+ }
+}
diff --git a/internal/version.go b/internal/version.go
index c0a2b88..a4ae7fc 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Summary: Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.11.0"
+const Version = "0.11.1"