From fb267966f7840df222338f57023273a993a73c9a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 11:14:27 +0300 Subject: use TOML not JSON for configuration --- docs/configuration.md | 75 +- docs/coverage.html | 2838 ++++++------ docs/coverage.out | 11632 ++++++++++++++++++++++++------------------------ 3 files changed, 7362 insertions(+), 7183 deletions(-) (limited to 'docs') diff --git a/docs/configuration.md b/docs/configuration.md index 3b862af..d52323c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,36 +7,39 @@ environment overrides, provider selection, and temperature behavior. The config file is optional. -- Location: `$XDG_CONFIG_HOME/hexai/config.json` (usually `~/.config/hexai/config.json`). +- Location: `$XDG_CONFIG_HOME/hexai/config.toml` (usually `~/.config/hexai/config.toml`). - Example: -```json -{ - "max_tokens": 4000, - "context_mode": "always-full", - "context_window_lines": 120, - "max_context_tokens": 4000, - "log_preview_limit": 100, - "completion_debounce_ms": 200, - "completion_throttle_ms": 0, - "no_disk_io": true, - "trigger_characters": [".", ":", "/", "_", " " ], - "inline_open": ">", - "inline_close": ">", - "chat_suffix": ">", - "chat_prefixes": ["?", "!", ":", ";"], - "coding_temperature": 0.2, - "provider": "ollama", - "copilot_model": "gpt-4o-mini", - "copilot_base_url": "https://api.githubcopilot.com", - "copilot_temperature": 0.2, - "openai_model": "gpt-4.1", - "openai_base_url": "https://api.openai.com/v1", - "openai_temperature": 0.2, - "ollama_model": "qwen3-coder:30b-a3b-q4_K_M", - "ollama_base_url": "http://localhost:11434", - "ollama_temperature": 0.2 -} +```toml +max_tokens = 4000 +context_mode = "always-full" +context_window_lines = 120 +max_context_tokens = 4000 +log_preview_limit = 100 +completion_debounce_ms = 200 +completion_throttle_ms = 0 +# no_disk_io is reserved for future use +trigger_characters = [".", ":", "/", "_", " "] +inline_open = ">" +inline_close = ">" +chat_suffix = ">" +chat_prefixes = ["?", "!", ":", ";"] +coding_temperature = 0.2 + +# choose one provider: openai | copilot | ollama +provider = "ollama" + +copilot_model = "gpt-4o-mini" +copilot_base_url = "https://api.githubcopilot.com" +copilot_temperature = 0.2 + +openai_model = "gpt-4.1" +openai_base_url = "https://api.openai.com/v1" +openai_temperature = 0.2 + +ollama_model = "qwen3-coder:30b-a3b-q4_K_M" +ollama_base_url = "http://localhost:11434" +ollama_temperature = 0.2 ``` Key fields: @@ -60,14 +63,12 @@ Key fields: Defaults use `>` for inline prompts and chat suffix. You can change them, e.g.: -```json -{ - "inline_open": "<", - "inline_close": ">", - "chat_suffix": "/", - "chat_prefixes": ["?", "!"], - "trigger_characters": [".", ":", "/", "_", " "] -} +```toml +inline_open = "<" +inline_close = ">" +chat_suffix = "/" +chat_prefixes = ["?", "!"] +trigger_characters = [".", ":", "/", "_", " "] ``` Notes: @@ -77,7 +78,7 @@ Notes: ## Environment overrides - All config-file options can be overridden by environment variables prefixed with `HEXAI_`. -- Env values take precedence over `config.json`. +- Env values take precedence over `config.toml`. - Examples: - `HEXAI_PROVIDER`, `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`, `HEXAI_LOG_PREVIEW_LIMIT` - `HEXAI_CODING_TEMPERATURE` diff --git a/docs/coverage.html b/docs/coverage.html index d940029..49b89df 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,7 +59,7 @@ - + @@ -83,19 +83,19 @@ - + - + - + - + @@ -178,117 +178,118 @@ func main() { } - @@ -788,6 +853,7 @@ package llm import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -797,7 +863,6 @@ import ( "strings" "time" - "encoding/base64" appver "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/logging" ) @@ -946,10 +1011,14 @@ func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64 } 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) + 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) } func handleCopilotNon2xx(resp *http.Response, start time.Time) error { @@ -978,55 +1047,73 @@ func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatRespons // --- Copilot session token management --- type ghCopilotTokenResp struct { - Token string `json:"token"` + Token string `json:"token"` } 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) { - return nil - } - 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) - if err != nil { return err } - 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() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("copilot token http error: %d", resp.StatusCode) - } - 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") } - // Parse JWT exp - exp := parseJWTExp(out.Token) - if exp.IsZero() { exp = time.Now().Add(10 * time.Minute) } - c.sessionToken = out.Token - c.tokenExpiry = exp - return nil + // If token valid for >60s, reuse + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { + return nil + } + 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) + if err != nil { + return err + } + 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() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("copilot token http error: %d", resp.StatusCode) + } + 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") + } + // Parse JWT exp + 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 { - parts := strings.Split(token, ".") - 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) } + parts := strings.Split(token, ".") + 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"` } - return time.Time{} - } - var payload struct{ Exp int64 `json:"exp"` } - _ = json.Unmarshal(b, &payload) - if payload.Exp == 0 { return time.Time{} } - return time.Unix(payload.Exp, 0) + _ = json.Unmarshal(b, &payload) + if payload.Exp == 0 { + return time.Time{} + } + return time.Unix(payload.Exp, 0) } func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err } @@ -1034,99 +1121,120 @@ func parseInt64(s string) (int64, error) { var n in // --- Copilot headers --- func (c *copilotClient) headersChat() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GitHubCopilotChat/0.8.0", - "Editor-Plugin-Version": "copilot-chat/0.8.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "conversation-panel", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GitHubCopilotChat/0.8.0", + "Editor-Plugin-Version": "copilot-chat/0.8.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "conversation-panel", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h } func (c *copilotClient) headersGhost() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "*/*", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GithubCopilot/1.155.0", - "Editor-Plugin-Version": "copilot/1.155.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "copilot-ghost", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "*/*", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GithubCopilot/1.155.0", + "Editor-Plugin-Version": "copilot/1.155.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "copilot-ghost", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h } func randHex(n int) string { - const hex = "0123456789abcdef" - b := make([]byte, n) - for i := range b { - b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] - } - return string(b) + const hex = "0123456789abcdef" + b := make([]byte, n) + for i := range b { + b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] + } + 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) { - 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 - body := map[string]any{ - "extra": map[string]any{ - "language": language, - "next_indent": 0, - "prompt_tokens": 500, - "suffix_tokens": 400, - "trim_by_indentation": true, - }, - "max_tokens": maxTokens, - "n": n, - "nwo": "hexai", - "prompt": prompt, - "stop": []string{"\n\n"}, - "stream": true, - "suffix": suffix, - "temperature": temperature, - "top_p": 1, - } - buf, _ := json.Marshal(body) - 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() - 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) - 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 } - } - 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 + 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 + body := map[string]any{ + "extra": map[string]any{ + "language": language, + "next_indent": 0, + "prompt_tokens": 500, + "suffix_tokens": 400, + "trim_by_indentation": true, + }, + "max_tokens": maxTokens, + "n": n, + "nwo": "hexai", + "prompt": prompt, + "stop": []string{"\n\n"}, + "stream": true, + "suffix": suffix, + "temperature": temperature, + "top_p": 1, + } + buf, _ := json.Marshal(body) + 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() + 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) + 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 + } + } + 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 } // newLineDataReader wraps a streaming body and exposes a JSON decoder that @@ -1685,20 +1793,20 @@ type Client interface { // token-by-token streaming responses. Callers can type-assert to Streamer and