bootstrap project
This commit is contained in:
commit
03c1b9d105
7 changed files with 401 additions and 0 deletions
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
24
LICENSE
Normal 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
22
README.md
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module git.thermokar.st/thermokarst/copilot-proxy
|
||||||
|
|
||||||
|
go 1.20
|
171
main.go
Normal file
171
main.go
Normal 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
128
main_test.go
Normal 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
|
||||||
|
}
|
25
st.thermokar.copilot-proxy.plist
Normal file
25
st.thermokar.copilot-proxy.plist
Normal 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>
|
Loading…
Add table
Reference in a new issue