diff options
Diffstat (limited to 'internal/llm/copilot.go')
| -rw-r--r-- | internal/llm/copilot.go | 412 |
1 files changed, 0 insertions, 412 deletions
diff --git a/internal/llm/copilot.go b/internal/llm/copilot.go deleted file mode 100644 index 43419ea..0000000 --- a/internal/llm/copilot.go +++ /dev/null @@ -1,412 +0,0 @@ -// Summary: GitHub Copilot client for chat and Codex-style code completion. -package llm - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" - - appver "codeberg.org/snonux/hexai/internal" - "codeberg.org/snonux/hexai/internal/logging" -) - -// copilotClient implements Client against GitHub Copilot's Chat Completions API. -type copilotClient struct { - httpClient *http.Client - apiKey string - baseURL string - defaultModel string - chatLogger logging.ChatLogger - defaultTemperature *float64 - - // cached Copilot session token retrieved from GitHub API using apiKey - sessionToken string - tokenExpiry time.Time -} - -type copilotChatRequest struct { - Model string `json:"model"` - Messages []copilotMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` -} - -type copilotMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type copilotChatResponse struct { - Choices []struct { - Index int `json:"index"` - Message struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - Type string `json:"type"` - Param any `json:"param"` - Code any `json:"code"` - } `json:"error,omitempty"` -} - -// Constructor (kept among the first functions by convention) -func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { - return newCopilotWithTimeout(baseURL, model, apiKey, defaultTemp, 0) -} - -func newCopilotWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { - if strings.TrimSpace(baseURL) == "" { - baseURL = "https://api.githubcopilot.com" - } - 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" - } - if timeoutSec <= 0 { - timeoutSec = 30 - } - return copilotClient{ - httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, - apiKey: apiKey, - baseURL: strings.TrimRight(baseURL, "/"), - defaultModel: model, - chatLogger: logging.NewChatLogger("copilot"), - defaultTemperature: defaultTemp, - } -} - -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 { - return "", err - } - o := Options{Model: c.defaultModel} - for _, opt := range opts { - opt(&o) - } - if o.Model == "" { - o.Model = c.defaultModel - } - start := time.Now() - logMessages := make([]struct{ Role, Content string }, len(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) - - req := buildCopilotChatRequest(o, messages, c.defaultTemperature) - body, err := json.Marshal(req) - if err != nil { - logging.Logf("llm/copilot ", "marshal error: %v", err) - return "", err - } - - 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 func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - if err := handleCopilotNon2xx(resp, start); err != nil { - return "", err - } - out, err := decodeCopilotChat(resp, start) - if err != nil { - return "", err - } - 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 - 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 } - -// helpers -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 { - req.Messages[i] = copilotMessage(m) - } - if o.Temperature != 0 { - req.Temperature = &o.Temperature - } else if defaultTemp != nil { - t := *defaultTemp - req.Temperature = &t - } - if o.MaxTokens > 0 { - req.MaxTokens = &o.MaxTokens - } - if len(o.Stop) > 0 { - req.Stop = o.Stop - } - return req -} - -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) -} - -func handleCopilotNon2xx(resp *http.Response, start time.Time) error { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - var apiErr copilotChatResponse - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - 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) - } - logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) - return fmt.Errorf("copilot http error: status %d", resp.StatusCode) -} - -func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) { - var out copilotChatResponse - 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 -} - -// --- Copilot session token management --- - -type ghCopilotTokenResp struct { - 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 func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - 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) - } - } - 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) -} - -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 { - _ = 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 -} - -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) -} - -// --- 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 func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - 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 -// decodes successive objects from lines prefixed by "data: ". -// (no streaming decoder needed; we parse whole body lines) |
