From 61137206eb7dd6a3df865591d710923838f59f18 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 5 Sep 2025 21:17:25 +0300 Subject: over 80% coverage now --- docs/coverage.html | 1697 ++++++++++++++++++++++++++-------------------------- 1 file changed, 864 insertions(+), 833 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 4976a0c..df02a90 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,45 +61,47 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + @@ -230,7 +232,7 @@ type App 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.json (including 0.0). t := 0.2 @@ -252,18 +254,18 @@ 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 { cfg := newDefaultConfig() 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 { + } 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 @@ -271,14 +273,14 @@ func Load(logger *log.Logger) App { } // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { + if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } - return cfg + return cfg } // Private helpers -func loadFromFile(path string, logger *log.Logger) (*App, error) { +func loadFromFile(path string, logger *log.Logger) (*App, error) { f, err := os.Open(path) if err != nil { if !os.IsNotExist(err) && logger != nil { @@ -286,7 +288,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return nil, err } - defer f.Close() + defer f.Close() dec := json.NewDecoder(f) var fileCfg App @@ -296,81 +298,81 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) return nil, err } - return &fileCfg, nil + return &fileCfg, nil } -func (a *App) mergeWith(other *App) { +func (a *App) mergeWith(other *App) { a.mergeBasics(other) a.mergeProviderFields(other) } // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { +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 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { + if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } + if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } + if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } // 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.json") } else { home, err := os.UserHomeDir() @@ -379,29 +381,29 @@ func getConfigPath() (string, error) { } configPath = filepath.Join(home, ".config", "hexai", "config.json") } - 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 == "" { return 0, false } + if v == "" { return 0, false } n, err := strconv.Atoi(v) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } ; return 0, false } return n, true } - parseFloatPtr := func(k string) (*float64, bool) { + parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { return nil, false } + if v == "" { return nil, false } f, err := strconv.ParseFloat(v, 64) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } @@ -410,34 +412,34 @@ 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 { @@ -447,24 +449,24 @@ 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 } // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s; any = true } - if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f; any = true } + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s; any = true } + if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s; any = true } + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f; any = true } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s; any = true } - if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { out.OllamaModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f; any = true } + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s; any = true } + if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { out.OllamaModel = s; any = true } + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f; any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s; any = true } - if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { out.CopilotModel = s; any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f; any = true } + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s; any = true } + if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { out.CopilotModel = s; any = true } + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f; any = true } - if !any { + if !any { return nil } return &out @@ -492,12 +494,12 @@ import ( // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. -func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) client, err := newClientFromConfig(cfg) - if err != nil { + if err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } @@ -507,35 +509,35 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. // RunWithClient executes the CLI flow using an already-constructed client. // Useful for testing and embedding. -func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error { +func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error { input, err := readInput(stdin, args) if err != nil { fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) return err } - printProviderInfo(stderr, client) + printProviderInfo(stderr, client) msgs := buildMessages(input) - if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil { + if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err } - return nil + return nil } // readInput reads from stdin and args, then combines them per CLI rules. -func readInput(stdin io.Reader, args []string) (string, error) { +func readInput(stdin io.Reader, args []string) (string, error) { var stdinData string - if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { b, _ := io.ReadAll(bufio.NewReader(stdin)) stdinData = strings.TrimSpace(string(b)) } - argData := strings.TrimSpace(strings.Join(args, " ")) + argData := strings.TrimSpace(strings.Join(args, " ")) switch { - case stdinData != "" && argData != "": + case stdinData != "" && argData != "": return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil case stdinData != "": return stdinData, nil - case argData != "": + case argData != "": return argData, nil default: return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin") @@ -543,7 +545,7 @@ func readInput(stdin io.Reader, args []string) (string, error) { +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) { llmCfg := llm.Config{ Provider: cfg.Provider, OpenAIBaseURL: cfg.OpenAIBaseURL, @@ -558,59 +560,59 @@ func newClientFromConfig(cfg appconfig.App) (llm.Client, error) { + 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") } - return llm.NewFromConfig(llmCfg, oaKey, cpKey) + return llm.NewFromConfig(llmCfg, oaKey, cpKey) } // buildMessages creates system and user messages based on input content. -func buildMessages(input string) []llm.Message { +func buildMessages(input string) []llm.Message { lower := strings.ToLower(input) system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation." if strings.Contains(lower, "explain") { system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context." } - return []llm.Message{ + return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, } } // runChat executes the chat request, handling streaming and summary output. -func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { +func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { start := time.Now() var output string - if s, ok := client.(llm.Streamer); ok { + if s, ok := client.(llm.Streamer); ok { var b strings.Builder - if err := s.ChatStream(ctx, msgs, func(chunk string) { + if err := s.ChatStream(ctx, msgs, func(chunk string) { b.WriteString(chunk) fmt.Fprint(out, chunk) }); err != nil { return err } - output = b.String() + output = b.String() } else { txt, err := client.Chat(ctx, msgs) - if err != nil { + if err != nil { return err } - output = txt + output = txt fmt.Fprint(out, output) } - dur := time.Since(start) + dur := time.Since(start) fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output)) return nil } // printProviderInfo writes the provider/model line to stderr. -func printProviderInfo(errw io.Writer, client llm.Client) { +func printProviderInfo(errw io.Writer, client llm.Client) { fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) } @@ -804,16 +806,16 @@ type copilotChatResponse struct { } // Constructor (kept among the first functions by convention) -func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { +func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.githubcopilot.com" } - if strings.TrimSpace(model) == "" { + if strings.TrimSpace(model) == "" { // GitHub Models (Copilot API) commonly supports gpt-4o/gpt-4o-mini. // Default to a broadly available, cost-effective option. model = "gpt-4o-mini" } - return copilotClient{ + return copilotClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), @@ -823,27 +825,27 @@ func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client } -func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { +func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { if strings.TrimSpace(c.apiKey) == "" { return nilStringErr("missing Copilot API key") } // Ensure we have a fresh session token - if err := c.ensureSession(ctx); err != nil { + if err := c.ensureSession(ctx); err != nil { return "", err } - 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() 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(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) + c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) req := buildCopilotChatRequest(o, messages, c.defaultTemperature) body, err := json.Marshal(req) @@ -852,70 +854,70 @@ func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...Req return "", err } - endpoint := c.baseURL + "/chat/completions" + endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/copilot ", "POST %s", endpoint) resp, err := c.postJSON(ctx, endpoint, body, c.headersChat()) if err != nil { logging.Logf("llm/copilot ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err } - defer resp.Body.Close() - if err := handleCopilotNon2xx(resp, start); err != nil { + defer resp.Body.Close() + if err := handleCopilotNon2xx(resp, start); err != nil { return "", err } - out, err := decodeCopilotChat(resp, start) - if err != nil { + out, err := decodeCopilotChat(resp, start) + if err != nil { return "", err } - if len(out.Choices) == 0 { + if len(out.Choices) == 0 { logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("copilot: no choices returned") } - content := out.Choices[0].Message.Content + content := out.Choices[0].Message.Content logging.Logf("llm/copilot ", "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 copilotClient) Name() string { return "copilot" } -func (c copilotClient) DefaultModel() string { return c.defaultModel } +func (c copilotClient) Name() string { return "copilot" } +func (c copilotClient) DefaultModel() string { return c.defaultModel } // helpers -func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest { +func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest { req := copilotChatRequest{Model: o.Model} req.Messages = make([]copilotMessage, len(messages)) - for i, m := range messages { + for i, m := range messages { req.Messages[i] = copilotMessage{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 { req.MaxTokens = &o.MaxTokens } - if len(o.Stop) > 0 { + if len(o.Stop) > 0 { req.Stop = o.Stop } - return req + return req } -func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { +func (c copilotClient) postJSON(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 } - for k, v := range headers { req.Header.Set(k, v) } - return c.httpClient.Do(req) + for k, v := range headers { req.Header.Set(k, v) } + return c.httpClient.Do(req) } -func handleCopilotNon2xx(resp *http.Response, start time.Time) error { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { +func handleCopilotNon2xx(resp *http.Response, start time.Time) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } - var apiErr copilotChatResponse + var apiErr copilotChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" { + if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" { logging.Logf("llm/copilot ", "%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("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) } @@ -923,13 +925,13 @@ func handleCopilotNon2xx(resp *http.Response, start time.Time) error } -func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) { +func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) { var out copilotChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return copilotChatResponse{}, err } - return out, nil + return out, nil } // --- Copilot session token management --- @@ -938,59 +940,59 @@ type ghCopilotTokenResp struct { Token string `json:"token"` } -func (c *copilotClient) ensureSession(ctx context.Context) error { +func (c *copilotClient) ensureSession(ctx context.Context) error { // If token valid for >60s, reuse - if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { return nil } - if strings.TrimSpace(c.apiKey) == "" { + if strings.TrimSpace(c.apiKey) == "" { return errors.New("missing Copilot API key") } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "hexai/"+appver.Version) resp, err := c.httpClient.Do(req) if err != nil { return err } - defer resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("copilot token http error: %d", resp.StatusCode) } - var out ghCopilotTokenResp + var out ghCopilotTokenResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return err } - if strings.TrimSpace(out.Token) == "" { return errors.New("empty copilot session token") } + if strings.TrimSpace(out.Token) == "" { return errors.New("empty copilot session token") } // Parse JWT exp - exp := parseJWTExp(out.Token) - if exp.IsZero() { exp = time.Now().Add(10 * time.Minute) } - c.sessionToken = out.Token + exp := parseJWTExp(out.Token) + if exp.IsZero() { exp = time.Now().Add(10 * time.Minute) } + c.sessionToken = out.Token c.tokenExpiry = exp return nil } var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode -func parseJWTExp(token string) time.Time { +func parseJWTExp(token string) time.Time { parts := strings.Split(token, ".") - if len(parts) < 2 { return time.Time{} } - b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if len(parts) < 2 { return time.Time{} } + b, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 { if n, err2 := parseInt64(m[1]); err2 == nil { return time.Unix(n, 0) } } return time.Time{} } - var payload struct{ Exp int64 `json:"exp"` } + var payload struct{ Exp int64 `json:"exp"` } _ = json.Unmarshal(b, &payload) if payload.Exp == 0 { return time.Time{} } - return time.Unix(payload.Exp, 0) + return time.Unix(payload.Exp, 0) } -func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err } +func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err } // --- Copilot headers --- -func (c *copilotClient) headersChat() map[string]string { +func (c *copilotClient) headersChat() map[string]string { _ = c.ensureSession(context.Background()) h := map[string]string{ "Content-Type": "application/json; charset=utf-8", @@ -1008,7 +1010,7 @@ func (c *copilotClient) headersChat() map[string]string -func (c *copilotClient) headersGhost() map[string]string { +func (c *copilotClient) headersGhost() map[string]string { _ = c.ensureSession(context.Background()) h := map[string]string{ "Content-Type": "application/json; charset=utf-8", @@ -1026,23 +1028,23 @@ func (c *copilotClient) headersGhost() map[string]string -func randHex(n int) string { +func randHex(n int) string { const hex = "0123456789abcdef" b := make([]byte, n) - for i := range b { + for i := range b { b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] } - return string(b) + return string(b) } // --- Codex-style code completion --- // CodeCompletion implements CodeCompleter; returns up to n suggestions. -func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) { +func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) { if strings.TrimSpace(c.apiKey) == "" { return nil, errors.New("missing Copilot API key") } - if err := c.ensureSession(ctx); err != nil { return nil, err } - if n <= 0 { n = 1 } - maxTokens := 500 + if err := c.ensureSession(ctx); err != nil { return nil, err } + if n <= 0 { n = 1 } + maxTokens := 500 body := map[string]any{ "extra": map[string]any{ "language": language, @@ -1065,25 +1067,25 @@ func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) if err != nil { return nil, err } - defer resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) } // Read all and parse lines that start with "data: " accumulating by index - raw, _ := io.ReadAll(resp.Body) + raw, _ := io.ReadAll(resp.Body) byIndex := make(map[int]string) lines := strings.Split(string(raw), "\n") - for _, ln := range lines { - if !strings.HasPrefix(ln, "data: ") { continue } - var evt struct{ Choices []struct{ Index int `json:"index"`; Text string `json:"text"` } `json:"choices"` } - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil { continue } - for _, ch := range evt.Choices { byIndex[ch.Index] += ch.Text } + for _, ln := range lines { + if !strings.HasPrefix(ln, "data: ") { continue } + var evt struct{ Choices []struct{ Index int `json:"index"`; Text string `json:"text"` } `json:"choices"` } + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil { continue } + for _, ch := range evt.Choices { byIndex[ch.Index] += ch.Text } } - out := make([]string, 0, len(byIndex)) - for i := 0; i < n; i++ { - if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" { out = append(out, s) } + out := make([]string, 0, len(byIndex)) + for i := 0; i < n; i++ { + if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" { out = append(out, s) } } - return out, nil + return out, nil } // newLineDataReader wraps a streaming body and exposes a JSON decoder that @@ -1134,14 +1136,14 @@ type ollamaChatResponse struct { } // Constructor (kept among the first functions by convention) -func newOllama(baseURL, model string, defaultTemp *float64) Client { +func newOllama(baseURL, model string, defaultTemp *float64) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "http://localhost:11434" } - if strings.TrimSpace(model) == "" { + if strings.TrimSpace(model) == "" { model = "qwen3-coder:30b-a3b-q4_K_M`" } - return ollamaClient{ + return ollamaClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, @@ -1150,16 +1152,16 @@ func newOllama(baseURL, model string, defaultTemp *float64) Client { +func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { 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 := buildOllamaRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) @@ -1167,47 +1169,47 @@ func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...Requ return "", err } - endpoint := c.baseURL + "/api/chat" + endpoint := c.baseURL + "/api/chat" logging.Logf("llm/ollama ", "POST %s", endpoint) resp, err := c.doJSON(ctx, endpoint, body) - if err != nil { + if err != nil { logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err } - defer resp.Body.Close() - if err := handleOllamaNon2xx(resp, start); err != nil { + defer resp.Body.Close() + if err := handleOllamaNon2xx(resp, start); err != nil { return "", err } - var out ollamaChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + var out ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return "", err } - if strings.TrimSpace(out.Message.Content) == "" { + if strings.TrimSpace(out.Message.Content) == "" { logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) return "", errors.New("ollama: empty content") } - content := out.Message.Content + content := out.Message.Content logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) return content, nil } // Provider metadata -func (c ollamaClient) Name() string { return "ollama" } -func (c ollamaClient) DefaultModel() string { return c.defaultModel } +func (c ollamaClient) Name() string { return "ollama" } +func (c ollamaClient) DefaultModel() string { return c.defaultModel } // Streaming support (optional) -func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error { +func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error { 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 := buildOllamaRequest(o, messages, c.defaultTemperature, true) body, err := json.Marshal(req) @@ -1215,96 +1217,96 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt return err } - endpoint := c.baseURL + "/api/chat" + endpoint := c.baseURL + "/api/chat" logging.Logf("llm/ollama ", "POST %s (stream)", endpoint) resp, err := c.doJSON(ctx, endpoint, body) if err != nil { logging.Logf("llm/ollama ", "%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 := handleOllamaNon2xx(resp, start); err != nil { return err } - dec := json.NewDecoder(resp.Body) - for { + dec := json.NewDecoder(resp.Body) + for { var ev ollamaChatResponse - if err := dec.Decode(&ev); err != nil { + if err := dec.Decode(&ev); err != nil { if errors.Is(err, io.EOF) { break } - logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) return err } - if strings.TrimSpace(ev.Error) != "" { + if strings.TrimSpace(ev.Error) != "" { logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase) return fmt.Errorf("ollama stream error: %s", ev.Error) } - if s := ev.Message.Content; strings.TrimSpace(s) != "" { + if s := ev.Message.Content; strings.TrimSpace(s) != "" { onDelta(s) } - if ev.Done { + if ev.Done { break } } - logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) + logging.Logf("llm/ollama ", "stream end