From c3c71345db9086392cd9b7529c7f5287009c226e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 24 Sep 2025 23:21:43 +0300 Subject: Add runtime config store and reload command --- docs/coverage.html | 2192 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 1265 insertions(+), 927 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 6828b9a..4a3153b 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ - + @@ -71,7 +71,7 @@ - + @@ -81,13 +81,13 @@ - + - + @@ -99,41 +99,45 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + @@ -349,7 +353,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { +func newDefaultConfig() App { // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -405,29 +409,40 @@ func newDefaultConfig() App { // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } + +// LoadOptions tune how configuration is loaded at runtime. +type LoadOptions struct { + // IgnoreEnv skips applying environment overrides when true. + IgnoreEnv bool +} + +// LoadWithOptions reads configuration and applies the requested loading options. +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { cfg := newDefaultConfig() - if logger == nil { + if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) } - configPath, err := getConfigPath() + configPath, err := getConfigPath() if err != nil { logger.Printf("%v", err) - // Even if config path cannot be resolved, still allow env overrides below. - } else { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { + // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below. + } else { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below. + // apply any environment overrides below (unless disabled). } - // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { - cfg.mergeWith(envCfg) - } - return cfg + if !opts.IgnoreEnv { + // Environment overrides (take precedence over file) + if envCfg := loadFromEnv(logger); envCfg != nil { + cfg.mergeWith(envCfg) + } + } + return cfg } // Private helpers @@ -496,7 +511,7 @@ type sectionOpenAI struct { Presets map[string]string `toml:"presets"` } -func (s sectionOpenAI) isZero() bool { +func (s sectionOpenAI) isZero() bool { return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 } @@ -594,11 +609,11 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App { +func (fc *fileConfig) toApp() App { out := App{} // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil { + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil { tmp := App{ MaxTokens: fc.General.MaxTokens, ContextMode: fc.General.ContextMode, @@ -610,13 +625,13 @@ func (fc *fileConfig) toApp() App { } // logging - if (fc.Logging != sectionLogging{}) { + if (fc.Logging != sectionLogging{}) { tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) } // completion - if (fc.Completion != sectionCompletion{}) { + if (fc.Completion != sectionCompletion{}) { tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, @@ -626,31 +641,31 @@ func (fc *fileConfig) toApp() App { } // triggers - if len(fc.Triggers.TriggerCharacters) > 0 { + if len(fc.Triggers.TriggerCharacters) > 0 { tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) } // inline - if (fc.Inline != sectionInline{}) { + if (fc.Inline != sectionInline{}) { tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) } // chat - if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { + if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) } // provider - if strings.TrimSpace(fc.Provider.Name) != "" { + if strings.TrimSpace(fc.Provider.Name) != "" { tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) } // openai - if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { + if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.resolvedModel(), @@ -660,7 +675,7 @@ func (fc *fileConfig) toApp() App { } // copilot - if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { + if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -670,7 +685,7 @@ func (fc *fileConfig) toApp() App { } // ollama - if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { + if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -681,7 +696,7 @@ func (fc *fileConfig) toApp() App { // prompts // completion - if (fc.Prompts.Completion != sectionPromptsCompletion{}) { + if (fc.Prompts.Completion != sectionPromptsCompletion{}) { if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral } @@ -702,11 +717,11 @@ func (fc *fileConfig) toApp() App { } } // chat - if strings.TrimSpace(fc.Prompts.Chat.System) != "" { + if strings.TrimSpace(fc.Prompts.Chat.System) != "" { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" || @@ -763,7 +778,7 @@ func (fc *fileConfig) toApp() App { } } // cli - if (fc.Prompts.CLI != sectionPromptsCLI{}) { + if (fc.Prompts.CLI != sectionPromptsCLI{}) { if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem } @@ -772,24 +787,24 @@ func (fc *fileConfig) toApp() App { } } // provider-native - if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { + if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion } // tmux - if (fc.Tmux != sectionTmux{}) { + if (fc.Tmux != sectionTmux{}) { out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) } // stats - if fc.Stats.WindowMinutes > 0 { + if fc.Stats.WindowMinutes > 0 { out.StatsWindowMinutes = fc.Stats.WindowMinutes } - return out + return out } -func loadFromFile(path string, logger *log.Logger) (*App, error) { +func loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) if err != nil { if !os.IsNotExist(err) && logger != nil { @@ -798,7 +813,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return nil, err } - var tables fileConfig + var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any @@ -811,7 +826,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) legacy := map[string]struct{}{ + legacy := map[string]struct{}{ "max_tokens": {}, "context_mode": {}, "context_window_lines": {}, "max_context_tokens": {}, "log_preview_limit": {}, "completion_debounce_ms": {}, "completion_throttle_ms": {}, "manual_invoke_min_prefix": {}, "trigger_characters": {}, "inline_open": {}, "inline_close": {}, @@ -820,8 +835,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + for k := range raw { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { continue } if _, isLegacy := legacy[k]; isLegacy { @@ -829,13 +844,13 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if logger != nil { + if logger != nil { logger.Printf("loaded configuration from %s (TOML)", path) } // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - tab := tables.toApp() + tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) if t, ok := raw["completion"].(map[string]any); ok { if v, present := t["manual_invoke_min_prefix"]; present { @@ -849,7 +864,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if t, ok := raw["logging"].(map[string]any); ok { + if t, ok := raw["logging"].(map[string]any); ok { if v, present := t["log_preview_limit"]; present { switch vv := v.(type) { case int64: @@ -861,142 +876,142 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return &tab, nil + return &tab, nil } -func (a *App) mergeWith(other *App) { +func (a *App) mergeWith(other *App) { a.mergeBasics(other) a.mergeProviderFields(other) a.mergePrompts(other) } // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { - if other.MaxTokens > 0 { +func (a *App) mergeBasics(other *App) { + if other.MaxTokens > 0 { a.MaxTokens = other.MaxTokens } - if s := strings.TrimSpace(other.ContextMode); s != "" { + if s := strings.TrimSpace(other.ContextMode); s != "" { a.ContextMode = s } - if other.ContextWindowLines > 0 { + if other.ContextWindowLines > 0 { a.ContextWindowLines = other.ContextWindowLines } - if other.MaxContextTokens > 0 { + if other.MaxContextTokens > 0 { a.MaxContextTokens = other.MaxContextTokens } - if other.LogPreviewLimit >= 0 { + if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } - if other.CodingTemperature != nil { // allow explicit 0.0 + if other.CodingTemperature != nil { // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature } - if other.ManualInvokeMinPrefix >= 0 { + if other.ManualInvokeMinPrefix >= 0 { a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix } - if other.CompletionDebounceMs > 0 { + if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { + if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { + if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.InlineOpen); s != "" { + if s := strings.TrimSpace(other.InlineOpen); s != "" { a.InlineOpen = s } - if s := strings.TrimSpace(other.InlineClose); s != "" { + if s := strings.TrimSpace(other.InlineClose); s != "" { a.InlineClose = s } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { + if s := strings.TrimSpace(other.ChatSuffix); s != "" { a.ChatSuffix = s } - if len(other.ChatPrefixes) > 0 { + if len(other.ChatPrefixes) > 0 { a.ChatPrefixes = slices.Clone(other.ChatPrefixes) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) { +func (a *App) mergePrompts(other *App) { // Completion if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral } - if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { + if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { a.PromptCompletionSystemParams = other.PromptCompletionSystemParams } - if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { + if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { a.PromptCompletionSystemInline = other.PromptCompletionSystemInline } - if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { + if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral } - if strings.TrimSpace(other.PromptCompletionUserParams) != "" { + if strings.TrimSpace(other.PromptCompletionUserParams) != "" { a.PromptCompletionUserParams = other.PromptCompletionUserParams } - if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { + if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader } // Provider-native - if strings.TrimSpace(other.PromptNativeCompletion) != "" { + if strings.TrimSpace(other.PromptNativeCompletion) != "" { a.PromptNativeCompletion = other.PromptNativeCompletion } // Chat - if strings.TrimSpace(other.PromptChatSystem) != "" { + if strings.TrimSpace(other.PromptChatSystem) != "" { a.PromptChatSystem = other.PromptChatSystem } // Code actions - if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem } - if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem } - if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser } - if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser } - if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser } // CLI - if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { + if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem } - if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { + if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { a.PromptCLIExplainSystem = other.PromptCLIExplainSystem } // Custom actions - if len(other.CustomActions) > 0 { + if len(other.CustomActions) > 0 { a.CustomActions = append([]CustomAction{}, other.CustomActions...) } - if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { + if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey } } // Validate checks custom actions and tmux settings for duplicates and consistency. -func (a App) Validate() error { +func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) @@ -1039,7 +1054,7 @@ func (a App) Validate() error { } } // Tmux custom menu hotkey validation - if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { + if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { if len([]rune(hk)) != 1 { return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) } @@ -1049,43 +1064,43 @@ func (a App) Validate() error { return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk) } } - return nil + return nil } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { +func (a *App) mergeProviderFields(other *App) { if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { a.OpenAIBaseURL = s } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { + if s := strings.TrimSpace(other.OpenAIModel); s != "" { a.OpenAIModel = s } - if other.OpenAITemperature != nil { // allow explicit 0.0 + if other.OpenAITemperature != nil { // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { a.OllamaBaseURL = s } - if s := strings.TrimSpace(other.OllamaModel); s != "" { + if s := strings.TrimSpace(other.OllamaModel); s != "" { a.OllamaModel = s } - if other.OllamaTemperature != nil { // allow explicit 0.0 + if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { a.CopilotBaseURL = s } - if s := strings.TrimSpace(other.CopilotModel); s != "" { + if s := strings.TrimSpace(other.CopilotModel); s != "" { a.CopilotModel = s } - if other.CopilotTemperature != nil { // allow explicit 0.0 + if other.CopilotTemperature != nil { // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature } } -func getConfigPath() (string, error) { +func getConfigPath() (string, error) { var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") } else { home, err := os.UserHomeDir() @@ -1094,36 +1109,36 @@ func getConfigPath() (string, error) { } configPath = filepath.Join(home, ".config", "hexai", "config.toml") } - return configPath, nil + return configPath, nil } // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. -func loadFromEnv(logger *log.Logger) *App { +func loadFromEnv(logger *log.Logger) *App { var out App var any bool // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } - parseInt := func(k string) (int, bool) { + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + parseInt := func(k string) (int, bool) { v := getenv(k) - if v == "" { + if v == "" { return 0, false } - n, err := strconv.Atoi(v) + n, err := strconv.Atoi(v) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } return 0, false } - return n, true + return n, true } - parseFloatPtr := func(k string) (*float64, bool) { + parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { + if v == "" { return nil, false } f, err := strconv.ParseFloat(v, 64) @@ -1136,43 +1151,43 @@ func loadFromEnv(logger *log.Logger) *App { return &f, true } - if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { out.MaxTokens = n any = true } - if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { + if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { out.ContextMode = s any = true } - if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { + if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { out.ContextWindowLines = n any = true } - if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { out.MaxContextTokens = n any = true } - if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { + if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { out.LogPreviewLimit = n any = true } - if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { + if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { out.ManualInvokeMinPrefix = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { out.CompletionDebounceMs = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { out.CompletionThrottleMs = n any = true } - if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { out.CodingTemperature = f any = true } - if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { + if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { parts := strings.Split(s, ",") out.TriggerCharacters = nil for _, p := range parts { @@ -1182,19 +1197,19 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_INLINE_OPEN"); s != "" { + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s any = true } - if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s any = true } - if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s any = true } - if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts { @@ -1204,17 +1219,17 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_PROVIDER"); s != "" { + if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s any = true } - modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) modelGeneric := strings.TrimSpace(getenv("HEXAI_MODEL")) providerLower := strings.ToLower(strings.TrimSpace(out.Provider)) forceUsed := false genericUsed := false - pickModel := func(providerName, specific string) (string, bool) { + pickModel := func(providerName, specific string) (string, bool) { specific = strings.TrimSpace(specific) nameLower := strings.ToLower(strings.TrimSpace(providerName)) if modelForce != "" { @@ -1227,10 +1242,10 @@ func loadFromEnv(logger *log.Logger) *App { return modelForce, true } } - if specific != "" { + if specific != "" { return specific, true } - if modelGeneric != "" { + if modelGeneric != "" { if providerLower == nameLower { return modelGeneric, true } @@ -1239,53 +1254,53 @@ func loadFromEnv(logger *log.Logger) *App { return modelGeneric, true } } - return "", false + return "", false } // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s any = true } - if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { + if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { out.OpenAIModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f any = true } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s any = true } - if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { + if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { out.OllamaModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s any = true } - if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { + if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { out.CopilotModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f any = true } - if !any { + if !any { return nil } - return &out + return &out } @@ -1632,10 +1647,10 @@ import ( ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } +func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } // StripFences removes surrounding markdown code fences. -func StripFences(s string) string { return textutil.StripCodeFences(s) } +func StripFences(s string) string { return textutil.StripCodeFences(s) } type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) @@ -1644,31 +1659,31 @@ type chatDoer interface { type providerNamer interface{ Name() string } -func providerOf(c any) string { +func providerOf(c any) string { if n, ok := c.(providerNamer); ok { return n.Name() } - return "llm" + return "llm" } -func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) { +func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) { var b strings.Builder - for i, d := range diags { + for i, d := range diags { if strings.TrimSpace(d) == "" { continue } - b.WriteString(strings.TrimSpace(d)) + b.WriteString(strings.TrimSpace(d)) if i < len(diags)-1 { b.WriteString("\n") } } - sys := cfg.PromptCodeActionDiagnosticsSystem + sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } @@ -1679,7 +1694,7 @@ func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, select return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) @@ -1691,7 +1706,7 @@ func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) { +func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) { // If user template is provided, prefer it and optional system if strings.TrimSpace(ca.User) != "" { sys := cfg.PromptCodeActionRewriteSystem @@ -1703,7 +1718,7 @@ func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appco return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } // Else, use fixed instruction through rewrite template - return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection) + return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection) } func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { @@ -1737,55 +1752,55 @@ func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, er return out, nil } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) { +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs, opts...) if err != nil { return "", err } - out := strings.TrimSpace(StripFences(txt)) + out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(out) + recv := len(out) _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil { + if snap, err := stats.TakeSnapshot(); err == nil { minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeReqs := int64(0) - if pe, ok := snap.Providers[providerOf(client)]; ok { - if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { scopeReqs = mc.Reqs } } - scopeRPM := float64(scopeReqs) / minsWin + scopeRPM := float64(scopeReqs) / minsWin _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) } - return out, nil + return out, nil } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) []llm.RequestOption { +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 { + if cfg.CodingTemperature != nil { 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)) + opts = append(opts, llm.WithTemperature(temp)) } - return opts + return opts } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 20*time.Second) } @@ -1864,7 +1879,7 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error < return nil } -func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { +func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { switch kind { case ActionSkip: return parts.Selection, nil @@ -1898,8 +1913,8 @@ func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.Ap }) } -func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { +func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) }) } @@ -1916,17 +1931,17 @@ func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App }) } -func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runSimplify(cctx, cfg, client, parts.Selection) }) } -func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { if selectedCustom == nil { return parts.Selection, nil } - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) selectedCustom = nil return out, err @@ -1944,7 +1959,7 @@ func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconf }) } -func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) { +func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) { innerCtx, cancel := timeout(ctx) defer cancel() return fn(innerCtx) @@ -2400,6 +2415,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/runtimeconfig" "codeberg.org/snonux/hexai/internal/stats" ) @@ -2434,36 +2450,54 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er // RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env. // When factory is nil, lsp.NewServer is used. -func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { +func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { normalizeLoggingConfig(&cfg) if err := cfg.Validate(); err != nil { logger.Fatalf("invalid config: %v", err) } - client = buildClientIfNil(cfg, client) + client = buildClientIfNil(cfg, client) factory = ensureFactory(factory) - opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client) + store := runtimeconfig.New(cfg) + logContext := strings.TrimSpace(logPath) != "" + opts := makeServerOptions(cfg, logContext, client) + opts.ConfigStore = store server := factory(stdin, stdout, logger, opts) - if err := server.Run(); err != nil { + if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok { + store.Subscribe(func(oldCfg, newCfg appconfig.App) { + updated := newCfg + normalizeLoggingConfig(&updated) + if updated.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute) + } + if newClient := buildClientIfNil(updated, nil); newClient != nil { + client = newClient + } + opts := makeServerOptions(updated, logContext, client) + opts.ConfigStore = store + configurable.ApplyOptions(opts) + }) + } + if err := server.Run(); err != nil { logger.Fatalf("server error: %v", err) } - return nil + return nil } // --- helpers to keep RunWithFactory small --- -func normalizeLoggingConfig(cfg *appconfig.App) { +func normalizeLoggingConfig(cfg *appconfig.App) { cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) - if cfg.LogPreviewLimit >= 0 { + if cfg.LogPreviewLimit >= 0 { logging.SetLogPreviewLimit(cfg.LogPreviewLimit) } } -func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { - if client != nil { +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { + if client != nil { return client } - llmCfg := llm.Config{ + llmCfg := llm.Config{ Provider: cfg.Provider, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel, @@ -2477,25 +2511,25 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { + if strings.TrimSpace(oaKey) == "" { oaKey = os.Getenv("OPENAI_API_KEY") } // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" { + cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" { cpKey = os.Getenv("COPILOT_API_KEY") } - if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil { + if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil { logging.Logf("lsp ", "llm disabled: %v", err) return nil - } else { + } else { logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) return c } } -func ensureFactory(factory ServerFactory) ServerFactory { - if factory != nil { +func ensureFactory(factory ServerFactory) ServerFactory { + if factory != nil { return factory } return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner { @@ -2503,12 +2537,12 @@ func ensureFactory(factory ServerFactory) ServerFactory } -func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions { +func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions { // Map custom actions from appconfig to lsp type var customs []lsp.CustomAction - if len(cfg.CustomActions) > 0 { + if len(cfg.CustomActions) > 0 { customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) - for _, ca := range cfg.CustomActions { + for _, ca := range cfg.CustomActions { customs = append(customs, lsp.CustomAction{ ID: ca.ID, Title: ca.Title, @@ -2520,8 +2554,10 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls }) } } - return lsp.ServerOptions{ + return lsp.ServerOptions{ LogContext: logContext, + ConfigStore: nil, + Config: &cfg, MaxTokens: cfg.MaxTokens, ContextMode: cfg.ContextMode, WindowLines: cfg.ContextWindowLines, @@ -3004,7 +3040,7 @@ func newOllama(baseURL, model string, defaultTemp *float64) Client if strings.TrimSpace(model) == "" { - model = "qwen3-coder:30b-a3b-q4_K_M`" + model = "qwen3-coder:30b-a3b-q4_K_M" } return ollamaClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, @@ -3252,14 +3288,14 @@ type oaStreamChunk struct { // Constructor (kept among the first functions by convention) // newOpenAI constructs an OpenAI client using explicit configuration values. // The apiKey may be empty; calls will fail until a valid key is supplied. -func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client { - if strings.TrimSpace(baseURL) == "" { +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client { + if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com/v1" } - if strings.TrimSpace(model) == "" { + if strings.TrimSpace(model) == "" { model = "gpt-4.1" } - return openAIClient{ + return openAIClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: baseURL, @@ -3269,18 +3305,18 @@ func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client } -func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { +func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { if c.apiKey == "" { return nilStringErr("missing OpenAI API key") } - o := Options{Model: c.defaultModel} + o := Options{Model: c.defaultModel} for _, opt := range opts { opt(&o) } - if o.Model == "" { + if o.Model == "" { o.Model = c.defaultModel } - start := time.Now() + start := time.Now() c.logStart(false, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) @@ -3288,7 +3324,7 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ c.logf("marshal error: %v", err) return "", err } - endpoint := c.baseURL + "/chat/completions" + endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/openai ", "POST %s", endpoint) resp, err := c.doJSON(ctx, endpoint, body, map[string]string{ "Authorization": "Bearer " + c.apiKey, @@ -3297,41 +3333,41 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err } - defer resp.Body.Close() - if err := handleOpenAINon2xx(resp, start); err != nil { + defer resp.Body.Close() + if err := handleOpenAINon2xx(resp, start); err != nil { return "", err } - out, err := decodeOpenAIChat(resp, start) + out, err := decodeOpenAIChat(resp, start) if err != nil { return "", err } - if len(out.Choices) == 0 { + if len(out.Choices) == 0 { logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("openai: no choices returned") } - content := out.Choices[0].Message.Content + content := out.Choices[0].Message.Content logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) return content, nil } // Provider metadata -func (c openAIClient) Name() string { return "openai" } -func (c openAIClient) DefaultModel() string { return c.defaultModel } +func (c openAIClient) Name() string { return "openai" } +func (c openAIClient) DefaultModel() string { return c.defaultModel } // Streaming support (optional) -func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error { +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error { if c.apiKey == "" { return errors.New("missing OpenAI API key") } - o := Options{Model: c.defaultModel} + o := Options{Model: c.defaultModel} for _, opt := range opts { opt(&o) } - if o.Model == "" { + if o.Model == "" { o.Model = c.defaultModel } - start := time.Now() + start := time.Now() c.logStart(true, o, messages) req := buildOAChatRequest(o, messages, c.defaultTemperature, true) body, err := json.Marshal(req) @@ -3339,7 +3375,7 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt c.logf("marshal error: %v", err) return err } - endpoint := c.baseURL + "/chat/completions" + endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/openai ", "POST %s (stream)", endpoint) resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{ "Authorization": "Bearer " + c.apiKey, @@ -3348,15 +3384,15 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err } - defer resp.Body.Close() + defer resp.Body.Close() if err := handleOpenAINon2xx(resp, start); err != nil { return err } - if err := parseOpenAIStream(resp, start, onDelta); err != nil { + if err := parseOpenAIStream(resp, start, onDelta); err != nil { return err } - logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) return nil } @@ -3364,104 +3400,104 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt func (c openAIClient) logf(format string, args ...any) { logging.Logf("llm/openai ", format, args...) } // helpers extracted to keep methods small -func (c openAIClient) logStart(stream bool, o Options, messages []Message) { +func (c openAIClient) logStart(stream bool, o Options, messages []Message) { logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages { + for i, m := range messages { logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} } - c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) + c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) } -func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest { +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest { req := oaChatRequest{Model: o.Model, Stream: stream} req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages { + for i, m := range messages { req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} } - if o.Temperature != 0 { + if o.Temperature != 0 { req.Temperature = &o.Temperature - } else if defaultTemp != nil { + } else if defaultTemp != nil { t := *defaultTemp req.Temperature = &t } - if o.MaxTokens > 0 { + if o.MaxTokens > 0 { if requiresMaxCompletionTokens(o.Model) { req.MaxCompletionTokens = &o.MaxTokens - } else { + } else { req.MaxTokens = &o.MaxTokens } } - if len(o.Stop) > 0 { + if len(o.Stop) > 0 { req.Stop = o.Stop } // Enforce gpt-5 temperature constraints: only default (1.0) is supported. - if requiresMaxCompletionTokens(o.Model) { + if requiresMaxCompletionTokens(o.Model) { if req.Temperature == nil || *req.Temperature != 1.0 { t := 1.0 req.Temperature = &t logging.Logf("llm/openai ", "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) } } - return req + 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 { +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) { +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 { return nil, err } - req.Header.Set("Content-Type", "application/json") - for k, v := range headers { + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { req.Header.Set(k, v) } - return c.httpClient.Do(req) + return c.httpClient.Do(req) } -func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) { +func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) - for k, v := range headers { + for k, v := range headers { req.Header.Set(k, v) } - return c.httpClient.Do(req) + return c.httpClient.Do(req) } -func handleOpenAINon2xx(resp *http.Response, start time.Time) error { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { +func handleOpenAINon2xx(resp *http.Response, start time.Time) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } var apiErr oaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && apiErr.Error.Message != "" { + if apiErr.Error != nil && apiErr.Error.Message != "" { logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) return fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) } - logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) return fmt.Errorf("openai http error: status %d", resp.StatusCode) } -func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) { +func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) { var out oaChatResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return oaChatResponse{}, err } - return out, nil + return out, nil } -func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error { +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error { // Parse SSE: lines starting with "data: " containing JSON or [DONE] scanner := bufio.NewScanner(resp.Body) const maxBuf = 1024 * 1024 @@ -3469,22 +3505,22 @@ func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string scanner.Buffer(buf, maxBuf) for scanner.Scan() { line := scanner.Text() - if !strings.HasPrefix(line, "data: ") { + if !strings.HasPrefix(line, "data: ") { continue } - payload := strings.TrimPrefix(line, "data: ") + payload := strings.TrimPrefix(line, "data: ") if strings.TrimSpace(payload) == "[DONE]" { break } - var chunk oaStreamChunk + var chunk oaStreamChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { continue } - if chunk.Error != nil && chunk.Error.Message != "" { + if chunk.Error != nil && chunk.Error.Message != "" { logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) return fmt.Errorf("openai stream error: %s", chunk.Error.Message) } - for _, ch := range chunk.Choices { + for _, ch := range chunk.Choices { if ch.Delta.Content != "" { onDelta(ch.Delta.Content) } @@ -3556,8 +3592,8 @@ type Options struct { type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } -func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } @@ -3582,14 +3618,14 @@ type Config struct { // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" { + if p == "" { p = "openai" } - switch p { - case "openai": - if strings.TrimSpace(openAIAPIKey) == "" { + switch p { + case "openai": + if strings.TrimSpace(openAIAPIKey) == "" { return nil, errors.New("missing OPENAI_API_KEY for provider openai") } // Default temperature selection: @@ -3598,7 +3634,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro // 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)) + model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) if strings.HasPrefix(model, "gpt-5") { if cfg.OpenAITemperature == nil { v := 1.0 @@ -3607,11 +3643,11 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro v := 1.0 cfg.OpenAITemperature = &v } - } else if cfg.OpenAITemperature == nil { + } else if cfg.OpenAITemperature == nil { v := 0.2 cfg.OpenAITemperature = &v } - return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil + return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil case "ollama": if cfg.OllamaTemperature == nil { t := 0.2 @@ -3685,7 +3721,7 @@ type ChatLogger struct { } // NewChatLogger creates a new ChatLogger for a given provider. -func NewChatLogger(provider string) ChatLogger { +func NewChatLogger(provider string) ChatLogger { return ChatLogger{Provider: provider} } @@ -3694,14 +3730,14 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens Role string Content string }, -) { +) { chatOrStream := "chat" - if stream { + if stream { chatOrStream = "stream" } - Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", + Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", chatOrStream, model, temp, maxTokens, len(stop), len(messages)) - for i, m := range messages { + for i, m := range messages { Logf("llm/"+cl.Provider+" ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), AnsiCyan, PreviewForLog(m.Content), AnsiBase) } @@ -3737,11 +3773,11 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { - if std == nil { +func Logf(prefix, format string, args ...any) { + if std == nil { return } - msg := fmt.Sprintf(format, args...) + msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset) } @@ -3750,21 +3786,86 @@ var logPreviewLimit int // 0 means unlimited // SetLogPreviewLimit sets the maximum number of characters to log for // request/response previews. Set to 0 for unlimited. -func SetLogPreviewLimit(n int) { logPreviewLimit = n } +func SetLogPreviewLimit(n int) { logPreviewLimit = n } // PreviewForLog returns the string truncated to the configured preview limit. -func PreviewForLog(s string) string { +func PreviewForLog(s string) string { if logPreviewLimit > 0 { if len(s) <= logPreviewLimit { return s } return s[:logPreviewLimit] + "…" } - return s + return s +} + + + -