diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-17 22:49:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-17 22:49:13 +0300 |
| commit | d059ae333fa1c89cb58d7fb56ead79cdba15d5db (patch) | |
| tree | ae65ad59c8590f71232a6abefee312b72ddf6d3e | |
| parent | 88103657fb230bb41217a06aa5602ae23e7acb8b (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.go | 13 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 11 | ||||
| -rw-r--r-- | internal/hexaicli/run_editor_behavior_test.go | 47 | ||||
| -rw-r--r-- | internal/llm/openai.go | 27 | ||||
| -rw-r--r-- | internal/llm/openai_request_test.go | 32 | ||||
| -rw-r--r-- | internal/llm/openai_temp_test.go | 43 | ||||
| -rw-r--r-- | internal/llm/provider.go | 22 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 12 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 4 | ||||
| -rw-r--r-- | internal/lsp/handlers_document.go | 2 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 10 | ||||
| -rw-r--r-- | internal/lsp/llm_request_opts_test.go | 31 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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" |
