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