summaryrefslogtreecommitdiff
path: root/internal/llm/copilot_http_test.go
blob: 4c2b7feb2d4e25b51a0156f13ea8bedbcb7c61ea (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
package llm

import (
    "context"
    "encoding/json"
    "io"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    "time"
    "encoding/base64"
)

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) {
    // Mock chat endpoint
    chatSrv := httptest.NewServer(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) {
    // Chat multi-choice: return two choices; client returns first content
    srv := httptest.NewServer(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 := httptest.NewServer(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 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) }
}

// 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 }