diff options
| author | paul@buetow.org <paul@buetow.org> | 2026-02-06 16:35:45 +0200 |
|---|---|---|
| committer | paul@buetow.org <paul@buetow.org> | 2026-02-06 16:35:45 +0200 |
| commit | 12a249282d5dd9dc2ee1e66f08d6acc26dd29eba (patch) | |
| tree | 5e9ae4fbd1696d1b668dfe0be791004a87fc7a6a /internal/llm | |
| parent | 89dc2aab0b6be2620766a4b4b750fa888641b89d (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.go | 412 | ||||
| -rw-r--r-- | internal/llm/copilot_http_test.go | 276 | ||||
| -rw-r--r-- | internal/llm/copilot_test.go | 35 | ||||
| -rw-r--r-- | internal/llm/openai_temp_test.go | 6 | ||||
| -rw-r--r-- | internal/llm/provider.go | 15 | ||||
| -rw-r--r-- | internal/llm/provider_more2_test.go | 12 | ||||
| -rw-r--r-- | internal/llm/provider_more_test.go | 10 | ||||
| -rw-r--r-- | internal/llm/provider_test.go | 8 |
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") } } |
