bootstrap project

This commit is contained in:
Matthew Ryan Dillon 2025-05-03 16:15:49 -04:00
commit 03c1b9d105
7 changed files with 401 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
copilot-proxy
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# End of https://www.toptal.com/developers/gitignore/api/go

24
LICENSE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# github copilot proxy server
a proxy server for github copilot, handling token management and request forwarding
## requirements
- go 1.20 or later
- a valid github pat set in the `GITHUB_AUTH_TOKEN` environment variable
## usage
```bash
go build -o copilot-proxy
GITHUB_AUTH_TOKEN=... ./copilot-proxy
```
## launchctl
```bash
cp st.thermokar.copilot-proxy.plist ~/Library/LaunchAgents/st.thermokar.copilot-proxy.plist
# edit as necessary
# log out and then back in
launchctl list | rg st.thermokar.copilot-proxy
```

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module git.thermokar.st/thermokarst/copilot-proxy
go 1.20

171
main.go Normal file
View file

@ -0,0 +1,171 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
)
const (
tokenExchangeURL = "https://api.github.com/copilot_internal/v2/token"
defaultOpenAIEndpoint = "https://api.githubcopilot.com"
userAgent = "curl/8.7.1"
)
type TokenResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
}
type ProxyServer struct {
authToken string
copilotToken string
tokenExpiry time.Time
proxyEndpoint string
mutex sync.RWMutex
client *http.Client
}
func NewProxyServer(authToken string) *ProxyServer {
return &ProxyServer{
authToken: authToken,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (p *ProxyServer) refreshTokenIfNeeded() error {
p.mutex.RLock()
tokenValid := p.copilotToken != "" && time.Now().Before(p.tokenExpiry)
p.mutex.RUnlock()
if tokenValid {
return nil
}
p.mutex.Lock()
defer p.mutex.Unlock()
if p.copilotToken != "" && time.Now().Before(p.tokenExpiry) {
return nil
}
req, err := http.NewRequest("GET", tokenExchangeURL, nil)
if err != nil {
return fmt.Errorf("failed to create token exchange request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", p.authToken))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("token exchange request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read token response body: %w", err)
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return fmt.Errorf("failed to parse token response: %w", err)
}
p.copilotToken = tokenResp.Token
p.tokenExpiry = time.Unix(tokenResp.ExpiresAt, 0)
p.proxyEndpoint = defaultOpenAIEndpoint
log.Printf("token refreshed, valid until: %v", p.tokenExpiry)
return nil
}
func (p *ProxyServer) proxyHandler(w http.ResponseWriter, r *http.Request) {
if err := p.refreshTokenIfNeeded(); err != nil {
log.Printf("error refreshing token: %v", err)
http.Error(w, "failed to authenticate with copilot", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
targetURL := p.proxyEndpoint + r.URL.Path
if p.proxyEndpoint == "" {
log.Printf("warning: proxy endpoint is empty, using default openai endpoint")
targetURL = defaultOpenAIEndpoint + r.URL.Path
}
if r.URL.RawQuery != "" {
targetURL += "?" + r.URL.RawQuery
}
log.Printf("proxying request to: %s", targetURL)
proxyReq, err := http.NewRequest(r.Method, targetURL, bytes.NewReader(body))
if err != nil {
http.Error(w, "error creating proxy request", http.StatusInternalServerError)
return
}
proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.copilotToken))
proxyReq.Header.Set("Copilot-Integration-Id", "vscode-chat")
proxyReq.Header.Set("Editor-Version", "Neovim/0.6.1")
resp, err := p.client.Do(proxyReq)
if err != nil {
log.Printf("error forwarding request to copilot: %v", err)
http.Error(w, "error forwarding request to copilot", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for name, values := range resp.Header {
for _, value := range values {
w.Header().Add(name, value)
}
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
log.Printf("error copying response body: %v", err)
}
}
func main() {
authToken := os.Getenv("GITHUB_AUTH_TOKEN")
if authToken == "" {
log.Fatal("GITHUB_AUTH_TOKEN environment variable is required")
}
proxy := NewProxyServer(authToken)
http.HandleFunc("/", proxy.proxyHandler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("starting proxy server on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}

128
main_test.go Normal file
View file

@ -0,0 +1,128 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
var mockTokenResponse = TokenResponse{
Token: "mock-token",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
func TestNewProxyServer(t *testing.T) {
authToken := "test-auth-token"
proxy := NewProxyServer(authToken)
if proxy.authToken != authToken {
t.Errorf("Expected authToken to be %s, got %s", authToken, proxy.authToken)
}
if proxy.client == nil {
t.Error("Expected http.Client to be initialized")
}
}
func TestRefreshTokenIfNeeded_ValidToken(t *testing.T) {
proxy := NewProxyServer("test-auth-token")
proxy.proxyEndpoint = "http://mock-endpoint.com"
proxy.copilotToken = mockTokenResponse.Token
proxy.client = &http.Client{
Transport: roundTripperFunc(func(req *http.Request) *http.Response {
expectedURL := proxy.proxyEndpoint
actualURL := req.URL.String()
// Normalize both URLs by trimming trailing slashes
expectedURL = strings.TrimRight(expectedURL, "/")
actualURL = strings.TrimRight(actualURL, "/")
if actualURL != expectedURL {
t.Errorf("Expected URL %s, got %s", expectedURL, actualURL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("mock-response")),
Header: make(http.Header),
}
}),
}
proxy.tokenExpiry = time.Now().Add(1 * time.Hour)
err := proxy.refreshTokenIfNeeded()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
func TestRefreshTokenIfNeeded_ExpiredToken(t *testing.T) {
proxy := NewProxyServer("test-auth-token")
proxy.copilotToken = ""
proxy.tokenExpiry = time.Now().Add(-1 * time.Hour)
proxy.client = &http.Client{
Transport: roundTripperFunc(func(req *http.Request) *http.Response {
if req.URL.String() != tokenExchangeURL {
t.Errorf("Expected URL %s, got %s", tokenExchangeURL, req.URL.String())
}
respBody, _ := json.Marshal(mockTokenResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(respBody)),
Header: make(http.Header),
}
}),
}
err := proxy.refreshTokenIfNeeded()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if proxy.copilotToken != mockTokenResponse.Token {
t.Errorf("Expected token to be %s, got %s", mockTokenResponse.Token, proxy.copilotToken)
}
}
func TestProxyHandler(t *testing.T) {
proxy := NewProxyServer("test-auth-token")
proxy.proxyEndpoint = "http://mock-endpoint.com"
proxy.copilotToken = mockTokenResponse.Token
proxy.tokenExpiry = time.Now().Add(1 * time.Hour)
proxy.client = &http.Client{
Transport: roundTripperFunc(func(req *http.Request) *http.Response {
expectedURL := strings.TrimRight(proxy.proxyEndpoint, "/")
actualURL := strings.TrimRight(req.URL.String(), "/")
if actualURL != expectedURL {
t.Errorf("Expected URL %s, got %s", expectedURL, actualURL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("mock-response")),
Header: make(http.Header),
}
}),
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test-body"))
w := httptest.NewRecorder()
proxy.proxyHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode)
}
}
type roundTripperFunc func(req *http.Request) *http.Response
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>st.thermokar.copilot-proxy</string>
<key>ProgramArguments</key>
<array>
<string>copilot-proxy</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GITHUB_AUTH_TOKEN</key>
<string>your-github-auth-token</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/log/copilot-proxy.log</string>
<key>StandardErrorPath</key>
<string>/log/copilot-proxy.err</string>
</dict>
</plist>