summaryrefslogtreecommitdiff
path: root/internal/llm
diff options
context:
space:
mode:
authorpaul@buetow.org <paul@buetow.org>2026-02-06 16:35:45 +0200
committerpaul@buetow.org <paul@buetow.org>2026-02-06 16:35:45 +0200
commit12a249282d5dd9dc2ee1e66f08d6acc26dd29eba (patch)
tree5e9ae4fbd1696d1b668dfe0be791004a87fc7a6a /internal/llm
parent89dc2aab0b6be2620766a4b4b750fa888641b89d (diff)
Remove GitHub Copilot provider support
Remove all GitHub Copilot integration from the codebase to streamline the supported provider set to OpenAI, OpenRouter, Anthropic, and Ollama. Changes: - Delete core Copilot implementation (copilot.go) and all related tests - Remove Copilot configuration fields from App struct and Config - Remove Copilot from provider factory and API key handling - Update all test files to replace Copilot references with other providers - Remove Copilot documentation from README, configuration guide, and examples - Remove Copilot section from config.toml.example All tests pass successfully after removal. Co-authored-by: Cursor <cursoragent@cursor.com>
Diffstat (limited to 'internal/llm')
-rw-r--r--internal/llm/copilot.go412
-rw-r--r--internal/llm/copilot_http_test.go276
-rw-r--r--internal/llm/copilot_test.go35
-rw-r--r--internal/llm/openai_temp_test.go6
-rw-r--r--internal/llm/provider.go15
-rw-r--r--internal/llm/provider_more2_test.go12
-rw-r--r--internal/llm/provider_more_test.go10
-rw-r--r--internal/llm/provider_test.go8
8 files changed, 8 insertions, 766 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)
diff --git a/internal/llm/copilot_http_test.go b/internal/llm/copilot_http_test.go
deleted file mode 100644
index 1371f71..0000000
--- a/internal/llm/copilot_http_test.go
+++ /dev/null
@@ -1,276 +0,0 @@
-package llm
-
-import (
- "context"
- "encoding/base64"
- "encoding/json"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "os"
- "strings"
- "testing"
- "time"
-)
-
-type rtFunc2 func(*http.Request) (*http.Response, error)
-
-func (f rtFunc2) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
-
-func TestCopilot_EnsureSession_AndChat_Success(t *testing.T) {
- if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" {
- t.Skip("skip network-bound tests in restricted environments")
- }
- // Mock chat endpoint
- chatSrv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/chat/completions" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- _ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"index": 0, "message": map[string]string{"role": "assistant", "content": "OK"}}}})
- }))
- defer chatSrv.Close()
- c := newCopilot(chatSrv.URL, "gpt-4o-mini", "APIKEY", f64p(0.1)).(copilotClient)
- // Intercept token endpoint to return a session token
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- // Fallback to default transport for chatSrv
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}})
- if err != nil || out != "OK" {
- t.Fatalf("copilot chat failed: %v %q", err, out)
- }
-}
-
-func TestCopilot_HandleNon2xx(t *testing.T) {
- b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": "bad", "type": "invalid"}})
- resp := &http.Response{StatusCode: 400, Body: io.NopCloser(bytesReader(b))}
- if err := handleCopilotNon2xx(resp, time.Now()); err == nil {
- t.Fatalf("expected error")
- }
-}
-
-func TestCopilot_CodeCompletion_Success(t *testing.T) {
- c := newCopilot("https://api.githubcopilot.com", "gpt-4o-mini", "API", f64p(0.1)).(copilotClient)
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- // Token endpoint
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- // Codex completion endpoint
- if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") {
- rw := httptest.NewRecorder()
- // two choices for index 0 and 1
- _, _ = rw.WriteString("data: {\"choices\":[{\"index\":0,\"text\":\"A\"}]}\n")
- _, _ = rw.WriteString("data: {\"choices\":[{\"index\":1,\"text\":\"B\"}]}\n")
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- out, err := c.CodeCompletion(context.Background(), "p", "s", 2, "go", 0.1)
- if err != nil || len(out) != 2 || out[0] != "A" || out[1] != "B" {
- t.Fatalf("codex: %v %#v", err, out)
- }
-}
-
-func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) {
- if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" {
- t.Skip("skip network-bound tests in restricted environments")
- }
- // Chat multi-choice: return two choices; client returns first content
- srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewEncoder(w).Encode(map[string]any{
- "choices": []map[string]any{
- {"index": 0, "finish_reason": "stop", "message": map[string]string{"role": "assistant", "content": "FIRST"}},
- {"index": 1, "finish_reason": "length", "message": map[string]string{"role": "assistant", "content": "SECOND"}},
- },
- })
- }))
- defer srv.Close()
- c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient)
- // Token success
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}})
- if err != nil || out != "FIRST" {
- t.Fatalf("copilot multi-choice: %v %q", err, out)
- }
-
- // Non-2xx with error body
- srv2 := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(403)
- _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "denied", "type": "forbidden"}})
- }))
- defer srv2.Close()
- c2 := newCopilot(srv2.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient)
- c2.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- if _, err := c2.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil {
- t.Fatalf("expected error for copilot non-2xx with error body")
- }
-}
-
-func TestCopilot_Chat_NoChoices_Error(t *testing.T) {
- if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" {
- t.Skip("skip network-bound tests in restricted environments")
- }
- srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}})
- }))
- defer srv.Close()
- c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient)
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil {
- t.Fatalf("expected error when no choices returned")
- }
-}
-
-func TestCopilot_Chat_DecodeError_StatusOK(t *testing.T) {
- if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" {
- t.Skip("skip network-bound tests in restricted environments")
- }
- // Chat returns 200 but invalid JSON; expect decode error
- srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = io.WriteString(w, "{invalid")
- }))
- defer srv.Close()
- c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient)
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil {
- t.Fatalf("expected decode error for invalid body")
- }
-}
-
-func TestCopilot_CodeCompletion_MalformedAndEmpty(t *testing.T) {
- c := newCopilot("https://api.githubcopilot.com", "gpt-4o-mini", "API", f64p(0.1)).(copilotClient)
- tr := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") {
- rw := httptest.NewRecorder()
- // malformed line
- _, _ = rw.WriteString("data: {bad}\n")
- // done; should produce empty suggestions
- _, _ = rw.WriteString("data: [DONE]\n")
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second}
- out, err := c.CodeCompletion(context.Background(), "p", "s", 1, "go", 0.1)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(out) != 0 {
- t.Fatalf("expected empty suggestions, got %#v", out)
- }
-
- // Now include one good chunk after malformed
- tr2 := rtFunc2(func(r *http.Request) (*http.Response, error) {
- if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" {
- rw := httptest.NewRecorder()
- _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"})
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") {
- rw := httptest.NewRecorder()
- _, _ = rw.WriteString("data: {bad}\n")
- _, _ = rw.WriteString("data: {\"choices\":[{\"index\":0,\"text\":\"OK\"}]}\n")
- _, _ = rw.WriteString("data: [DONE]\n")
- res := rw.Result()
- res.StatusCode = 200
- return res, nil
- }
- return http.DefaultTransport.RoundTrip(r)
- })
- c.httpClient = &http.Client{Transport: tr2, Timeout: 5 * time.Second}
- out2, err := c.CodeCompletion(context.Background(), "p", "s", 1, "go", 0.1)
- if err != nil || len(out2) != 1 || out2[0] != "OK" {
- t.Fatalf("unexpected: %v %#v", err, out2)
- }
-}
-
-func TestParseJWTExp_AndParseInt64(t *testing.T) {
- // Valid base64 payload
- payload := `{"exp": 1700000000}`
- b := base64.RawURLEncoding.EncodeToString([]byte(payload))
- tok := "x." + b + ".y"
- if tm := parseJWTExp(tok); tm.IsZero() {
- t.Fatalf("expected non-zero time")
- }
- if n, err := parseInt64("123"); err != nil || n != 123 {
- t.Fatalf("parseInt64: %v %d", err, n)
- }
-}
-
-func newIPv4Server(t *testing.T, handler http.Handler) *httptest.Server {
- t.Helper()
- l, err := net.Listen("tcp4", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("failed to listen on tcp4: %v", err)
- }
- srv := &httptest.Server{
- Listener: l,
- Config: &http.Server{Handler: handler},
- }
- srv.Start()
- return srv
-}
-
-// bytesReader wraps a byte slice with an io.ReadCloser without importing extra.
-type bytesReader []byte
-
-func (b bytesReader) Read(p []byte) (int, error) { n := copy(p, b); return n, io.EOF }
-func (b bytesReader) Close() error { return nil }
diff --git a/internal/llm/copilot_test.go b/internal/llm/copilot_test.go
deleted file mode 100644
index 8f15347..0000000
--- a/internal/llm/copilot_test.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package llm
-
-import "testing"
-
-func TestBuildCopilotChatRequest_FieldsAndDefaults(t *testing.T) {
- o := Options{
- Model: "gpt-x",
- Temperature: 0,
- MaxTokens: 123,
- Stop: []string{"X"},
- }
-
- msgs := []Message{{Role: "user", Content: "q"}}
- req := buildCopilotChatRequest(o, msgs, f64p(0.5))
-
- if req.Model != "gpt-x" {
- t.Fatalf("model mismatch: %q", req.Model)
- }
-
- if req.Temperature == nil || *req.Temperature != 0.5 {
- t.Fatalf("default temp not applied")
- }
-
- if req.MaxTokens == nil || *req.MaxTokens != 123 {
- t.Fatalf("max_tokens not applied")
- }
-
- if len(req.Stop) != 1 || req.Stop[0] != "X" {
- t.Fatalf("stop not applied")
- }
-
- if len(req.Messages) != 1 || req.Messages[0].Content != "q" {
- t.Fatalf("messages not copied")
- }
-}
diff --git a/internal/llm/openai_temp_test.go b/internal/llm/openai_temp_test.go
index 07abbd5..3d71b94 100644
--- a/internal/llm/openai_temp_test.go
+++ b/internal/llm/openai_temp_test.go
@@ -5,7 +5,7 @@ import "testing"
func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) {
// OpenAI, gpt-5.* → default temp 1.0 when not provided
cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0-preview"}
- c, err := NewFromConfig(cfg, "key", "", "", "")
+ c, err := NewFromConfig(cfg, "key", "", "")
if err != nil {
t.Fatalf("new: %v", err)
}
@@ -18,7 +18,7 @@ func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) {
}
// OpenAI, gpt-4.* → default temp 0.2 when not provided
cfg2 := Config{Provider: "openai", OpenAIModel: "gpt-4.1"}
- c2, err := NewFromConfig(cfg2, "key", "", "", "")
+ c2, err := NewFromConfig(cfg2, "key", "", "")
if err != nil {
t.Fatalf("new2: %v", err)
}
@@ -32,7 +32,7 @@ func TestNewFromConfig_DefaultTemp_UpgradeWhenGpt5AndDefault02(t *testing.T) {
// Simulate app-default of 0.2 while selecting a gpt-5 model: should upgrade to 1.0
v := 0.2
cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0", OpenAITemperature: &v}
- c, err := NewFromConfig(cfg, "key", "", "", "")
+ c, err := NewFromConfig(cfg, "key", "", "")
if err != nil {
t.Fatalf("new: %v", err)
}
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index 297f1f3..8230b53 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -78,10 +78,6 @@ type Config struct {
OllamaBaseURL string
OllamaModel string
OllamaTemperature *float64
- // Copilot options
- CopilotBaseURL string
- CopilotModel string
- CopilotTemperature *float64
// Anthropic options
AnthropicBaseURL string
AnthropicModel string
@@ -91,7 +87,7 @@ 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, openRouterAPIKey, copilotAPIKey, anthropicAPIKey string) (Client, error) {
+func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey string) (Client, error) {
p := strings.ToLower(strings.TrimSpace(cfg.Provider))
if p == "" {
p = "openai"
@@ -136,15 +132,6 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an
cfg.OllamaTemperature = &t
}
return newOllamaWithTimeout(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature, cfg.RequestTimeout), nil
- case "copilot":
- if strings.TrimSpace(copilotAPIKey) == "" {
- return nil, errors.New("missing COPILOT_API_KEY for provider copilot")
- }
- if cfg.CopilotTemperature == nil {
- t := 0.2
- cfg.CopilotTemperature = &t
- }
- return newCopilotWithTimeout(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature, cfg.RequestTimeout), nil
case "anthropic":
if strings.TrimSpace(anthropicAPIKey) == "" {
return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic")
diff --git a/internal/llm/provider_more2_test.go b/internal/llm/provider_more2_test.go
deleted file mode 100644
index 86b149a..0000000
--- a/internal/llm/provider_more2_test.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package llm
-
-import "testing"
-
-func TestNewFromConfig_Copilot(t *testing.T) {
- t.Setenv("COPILOT_API_KEY", "x")
- cfg := Config{Provider: "copilot", CopilotModel: "small"}
- c, err := NewFromConfig(cfg, "", "", "x", "")
- if err != nil || c == nil {
- t.Fatalf("copilot provider failed: %v %v", c, err)
- }
-}
diff --git a/internal/llm/provider_more_test.go b/internal/llm/provider_more_test.go
index caad912..8d7b133 100644
--- a/internal/llm/provider_more_test.go
+++ b/internal/llm/provider_more_test.go
@@ -13,17 +13,11 @@ func TestWithOptions_Apply(t *testing.T) {
}
}
-func TestNewFromConfig_Success_OpenAI_And_Copilot(t *testing.T) {
+func TestNewFromConfig_Success_OpenAI(t *testing.T) {
// OpenAI success
oc := Config{Provider: "openai", OpenAIBaseURL: "http://x", OpenAIModel: "gpt"}
- c, err := NewFromConfig(oc, "KEY", "", "", "")
+ c, err := NewFromConfig(oc, "KEY", "", "")
if err != nil || c == nil || c.Name() != "openai" || c.DefaultModel() == "" {
t.Fatalf("openai new: %v %v", c, err)
}
- // Copilot success
- cc := Config{Provider: "copilot", CopilotBaseURL: "http://x", CopilotModel: "gpt-4o-mini"}
- c2, err := NewFromConfig(cc, "", "", "KEY", "")
- if err != nil || c2 == nil || c2.Name() != "copilot" || c2.DefaultModel() == "" {
- t.Fatalf("copilot new: %v %v", c2, err)
- }
}
diff --git a/internal/llm/provider_test.go b/internal/llm/provider_test.go
index 46c7ea8..8ccba6e 100644
--- a/internal/llm/provider_test.go
+++ b/internal/llm/provider_test.go
@@ -6,15 +6,11 @@ import (
func TestNewFromConfig_DefaultsAndErrors(t *testing.T) {
// Unknown provider
- if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", "", ""); err == nil {
+ if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", ""); err == nil {
t.Fatalf("expected error for unknown provider")
}
// OpenAI missing key
- if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", "", ""); err == nil {
- t.Fatalf("expected key error")
- }
- // Copilot missing key
- if _, err := NewFromConfig(Config{Provider: "copilot", CopilotModel: "m"}, "", "", "", ""); err == nil {
+ if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", ""); err == nil {
t.Fatalf("expected key error")
}
}