diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad07e3..e78cf34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ main, dev ] + branches: [ dev ] pull_request: branches: [ main ] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 338886b..f5845d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,19 +9,46 @@ permissions: contents: write packages: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 + with: + version: v2.1 + release: + needs: verify runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.23' @@ -86,10 +113,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.23' @@ -112,36 +139,24 @@ jobs: echo "Built and gzipped binary: $GZ_BINARY_NAME" ls -la "$GZ_BINARY_NAME" - - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - tag_name: ${{ needs.release.outputs.version }} - files: ./github-copilot-svcs-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}.gz - body: | - ## Changes in ${{ needs.release.outputs.version }} - - Auto-generated release from main branch. - - ### Downloads - - Linux AMD64: `github-copilot-svcs-linux-amd64.gz` - - Linux ARM64: `github-copilot-svcs-linux-arm64.gz` - - macOS AMD64: `github-copilot-svcs-darwin-amd64.gz` - - macOS ARM64: `github-copilot-svcs-darwin-arm64.gz` - - Windows AMD64: `github-copilot-svcs-windows-amd64.exe.gz` - - Windows ARM64: `github-copilot-svcs-windows-arm64.exe.gz` + name: binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: ./github-copilot-svcs-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}.gz docker: needs: release runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -149,7 +164,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/${{ github.repository }} tags: | @@ -159,7 +174,7 @@ jobs: type=raw,value=latest - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . platforms: linux/amd64,linux/arm64 @@ -170,3 +185,41 @@ jobs: VERSION=${{ needs.release.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max + + create-release: + needs: [release, build, docker] + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: ./artifacts + + - name: Organize artifacts and generate checksums + run: | + mkdir -p ./release-assets + find ./artifacts -name "*.gz" -exec cp {} ./release-assets/ \; + ( + cd ./release-assets + sha256sum ./*.gz > SHA256SUMS + ) + ls -la ./release-assets/ + + - name: Create Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ needs.release.outputs.version }} + generate_release_notes: true + files: ./release-assets/* + body: | + ## Changes in ${{ needs.release.outputs.version }} + + Auto-generated release from main branch. + + ### Downloads + - Linux AMD64: `github-copilot-svcs-linux-amd64.gz` + - Linux ARM64: `github-copilot-svcs-linux-arm64.gz` + - macOS AMD64: `github-copilot-svcs-darwin-amd64.gz` + - macOS ARM64: `github-copilot-svcs-darwin-arm64.gz` + - Windows AMD64: `github-copilot-svcs-windows-amd64.exe.gz` + - Windows ARM64: `github-copilot-svcs-windows-arm64.exe.gz` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..091cfb8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Source code location: +- `cmd/` — Application entry points +- `internal/` — Core service modules (auth, config, API, middleware) +- `pkg/` — Shared utilities/packages +- `test/` — Integration and helper tests +- `config.example.json`, `Dockerfile`, `docker-compose.yml` — Example/config files + +## Build, Test, and Development Commands + +Key commands (via Makefile): +- `make build` — Build service binary +- `make run` — Start proxy server locally +- `make dev` — Hot reload (requires air) +- `make test` — Unit tests +- `make test-all` — All tests (unit + integration) +- `make test-coverage` — Coverage report +- `make lint` — Lint code (golangci-lint) +- `make fmt` — Format code + +Requires Go 1.23.0+ + +## Coding Style & Naming Conventions + +- Indentation: tabs (Go standard) +- Use camelCase or snake_case for names +- Exported Go identifiers: PascalCase +- Format code before PRs (`make fmt`), lint (`make lint`) + +## Testing Guidelines + +- Use Go `testing` package; name test files `_test.go`, test functions `TestXxx` +- Unit tests: `internal/` and `pkg/` +- Integration tests: `test/integration/` +- Run: `make test-all`, `make test-coverage` (aim for >=45% coverage in core logic) + +## Commit & Pull Request Guidelines + +- Commit messages: short, present-tense (e.g., "Refactor code structure") +- PRs: describe changes/reasoning, link issues, add screenshots for UI +- Ensure all tests pass & code is formatted +- Do not commit secrets or sensitive configs + +## Security & Configuration Tips + +- Store secrets in user-level config with permissions 0700 +- Never log sensitive data +- Only use HTTPS for credentials/tokens +- Do not push sensitive files; check `.gitignore` + +--- + +For help, open an issue or see the README troubleshooting section. diff --git a/Dockerfile b/Dockerfile index b8b8e51..1fe2734 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,4 +47,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1 # Run the binary -CMD ["./github-copilot-svcs", "run"] +CMD ["./github-copilot-svcs", "start"] diff --git a/README.md b/README.md index 740ec4a..a349f9a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,16 @@ This project provides a reverse proxy for GitHub Copilot, exposing OpenAI-compatible endpoints for use with tools and clients that expect the OpenAI API. It follows the authentication and token management approach used by [OpenCode](https://github.com/sst/opencode). +> **❗ IMPORTANT: Vision/Image capability is not available on all GitHub Copilot accounts!** +> - Even if your account supports GPT-4o, **vision features may not be enabled** for your Copilot subscription. +> - Some orgs and accounts do not have access to vision/image generation/analysis, or may have different levels of support than OpenAI direct accounts. +> - This proxy supports vision for Copilot accounts _if_ your Copilot subscription supports it. If your account does not, you will receive a relevant error from the upstream API. +> - For latest details, check your Copilot subscription or contact GitHub support for your organization/account. + ## Features - **OAuth Device Flow Authentication**: Secure authentication with GitHub Copilot using the same flow as OpenCode +- **Vision Support**: Full support for image/vision requests with base64-encoded images in OpenAI-compatible format - **Advanced Token Management**: - Proactive token refresh (refreshes at 20% of token lifetime, minimum 5 minutes) - Exponential backoff retry logic for failed token refreshes @@ -14,7 +21,7 @@ This project provides a reverse proxy for GitHub Copilot, exposing OpenAI-compat - Automatic retry with exponential backoff for chat completions (3 attempts) - Network error recovery and rate limiting handling - 30-second request timeout protection -- **OpenAI-Compatible API**: Exposes `/v1/chat/completions` and `/v1/models` endpoints +- **OpenAI-Compatible API**: Exposes `/v1/chat/completions`, `/v1/responses`, and `/v1/models` endpoints - **Request/Response Transformation**: Handles model name mapping and ensures OpenAI compatibility - **Configurable Port**: Default port 8081, configurable via CLI or config file - **Health Monitoring**: `/health` endpoint for service monitoring @@ -106,6 +113,21 @@ make docker-build # Build Docker image make docker-run # Run Docker container ``` +## Filtering Allowed Models + +You can control which models are available by specifying `allowed_models` in your config file (`config.json`). + +Example: +```json +{ + "allowed_models": ["gpt-4o", "claude-3.7-sonnet"] +} +``` + +- If set, both CLI and REST /v1/models lists are filtered and show a note. +- Proxy requests to /v1/chat/completions will only allow those models, rejecting others with HTTP 400. +- If omitted or set to null, all models are permitted (default behavior). + ## Building for Different OS/Architectures You can build binaries for different platforms using the following Makefile targets: @@ -152,7 +174,7 @@ make run docker run --rm \ -p 8081:8081 \ -v ~/.local/share/github-copilot-svcs:/home/appuser/.local/share/github-copilot-svcs \ - ghcr.io/privapps/github-copilot-svcs:0.0.2 + ghcr.io/privapps/github-copilot-svcs:latest ``` ## CLI Commands @@ -203,7 +225,7 @@ POST http://localhost:8081/v1/chat/completions Content-Type: application/json { - "model": "gpt-4", + "model": "gpt-4.1", "messages": [ {"role": "user", "content": "Hello, world!"} ], @@ -211,6 +233,19 @@ Content-Type: application/json } ``` +### Responses API +For GPT-5.x models (nano, mini, codex variants): +```bash +POST http://localhost:8081/v1/responses +Content-Type: application/json + +{ + "model": "gpt-5.6-luna", + "input": "Hello, world!", + "max_output_tokens": 100 +} +``` + ### Available Models ```bash GET http://localhost:8081/v1/models @@ -360,20 +395,22 @@ The authentication follows GitHub Copilot's OAuth device flow: ## Model Mapping -The proxy automatically maps common model names to GitHub Copilot models: +The proxy automatically maps common model names to GitHub Copilot models. Each model has an `api_type` field indicating which endpoint to use: + +### Chat Completions Models (`/v1/chat/completions`) +| Model | Provider | +|-------|----------| +| `gpt-4o`, `gpt-4.1` | OpenAI | +| `claude-haiku-4.5`, `claude-sonnet-5`, `claude-opus-4.8` | Anthropic | +| `gemini-3.5-flash`, `gemini-3.1-pro-preview` | Google | -| Input Model | GitHub Copilot Model | Provider | -|-------------|---------------------|----------| -| `gpt-4o`, `gpt-4.1` | As specified | OpenAI | -| `o3`, `o3-mini`, `o4-mini` | As specified | OpenAI | -| `claude-3.5-sonnet`, `claude-3.7-sonnet`, `claude-3.7-sonnet-thought` | As specified | Anthropic | -| `claude-opus-4`, `claude-sonnet-4` | As specified | Anthropic | -| `gemini-2.5-pro`, `gemini-2.0-flash-001` | As specified | Google | +### Responses API Models (`/v1/responses`) +| Model | Provider | +|-------|----------| +| `gpt-5.3-codex`, `gpt-5.4-mini` | OpenAI | +| `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra` | OpenAI | -**Supported Model Categories:** -- **OpenAI GPT Models**: GPT-4o, GPT-4.1, O3/O4 reasoning models -- **Anthropic Claude Models**: Claude 3.5/3.7 Sonnet variants, Claude Opus/Sonnet 4 -- **Google Gemini Models**: Gemini 2.0/2.5 Pro and Flash models +**Note:** Use the `/v1/models` endpoint to see all available models and their `api_type` field to determine which endpoint to use. ## Security @@ -398,8 +435,18 @@ The proxy automatically maps common model names to GitHub Copilot models: # Check if service is running curl http://localhost:8081/health -# View logs (if running in foreground) -./github-copilot-svcs run +# List available models with api_type field +curl http://localhost:8081/v1/models + +# Test chat completions +curl -X POST http://localhost:8081/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4.1","messages":[{"role":"user","content":"hi"}]}' + +# Test responses API +curl -X POST http://localhost:8081/v1/responses \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-5.6-luna","input":"hi","max_output_tokens":20}' ``` ### Port Conflicts @@ -413,13 +460,57 @@ curl http://localhost:8081/health ### Using with curl ```bash +# Chat Completions (for GPT-4.x, Claude, Gemini models) curl -X POST http://localhost:8081/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt-4", + "model": "gpt-4.1", "messages": [{"role": "user", "content": "Write a hello world in Python"}], "max_tokens": 100 }' + +# Responses API (for GPT-5.x nano/mini/codex models) +curl -X POST http://localhost:8081/v1/responses \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.6-luna", + "input": "Write a hello world in Python", + "max_output_tokens": 100 + }' +``` + +### Vision/Image Requests + +The proxy fully supports vision capabilities with base64-encoded images in OpenAI-compatible format: + +```bash +# Example with base64-encoded image +curl -X POST http://localhost:8081/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."}} + ] + }], + "max_tokens": 300 + }' +``` + +**Vision Features:** +- Supports multi-part message content (text + images) +- Accepts base64-encoded images as data URIs +- Supports `detail` parameter (`auto`, `low`, `high`) +- Compatible with vision-capable models (gpt-4o, claude-haiku-4.5, etc.) +- Backward compatible with text-only requests + +**Example Script:** +The repository includes `test_vision_proxy.sh` that demonstrates vision capabilities: +```bash +./test_vision_proxy.sh dog.jpeg "Describe this image in detail" ``` ### Using with OpenAI Python Client @@ -451,6 +542,26 @@ response = llm("Write a hello world in Python") print(response) ``` +### Using with Codex CLI + +To use this proxy with Codex CLI, add a model provider configuration to your Codex config file (e.g., `~/.codex/config.toml`): + +```toml +model = "gpt-5.6-terra" +model_provider = "local-ghcp" +model_reasoning_effort = "medium" + +[model_providers.local-ghcp] +name = "local-ghcp" +base_url = "http://localhost:8081/v1" +wire_api = "responses" +experimental_bearer_token = "sk-local" +requires_openai_auth = false +supports_websockets = false +``` + +This configures Codex to use the proxy's Responses API endpoint (`/v1/responses`) for GPT-5.x models. The `wire_api = "responses"` setting ensures Codex uses the correct transport. + ## Development ### Building from Source diff --git a/config.example.json b/config.example.json index cc803fd..504c95b 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,6 @@ { "port": 8081, + "allowed_models": null, "headers": { "user_agent": "GitHubCopilotChat/0.29.1", "editor_version": "vscode/1.102.3", diff --git a/go.mod b/go.mod index c8c69d7..6ee0444 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,3 @@ module github.com/privapps/github-copilot-svcs go 1.23.0 toolchain go1.23.5 - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - golang.org/x/sys v0.33.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect -) diff --git a/go.sum b/go.sum index 4d13703..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/internal/auth.go b/internal/auth.go index 22a8c54..28e37a8 100644 --- a/internal/auth.go +++ b/internal/auth.go @@ -82,7 +82,6 @@ func WithRefreshFunc(f func(cfg *Config) error) func(*AuthService) { } } - // Authenticate performs the full GitHub Copilot authentication flow func (s *AuthService) Authenticate(cfg *Config) error { now := time.Now().Unix() diff --git a/internal/cli.go b/internal/cli.go index 095486d..dc8a5e9 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -2,11 +2,12 @@ package internal import ( "encoding/json" + "errors" "flag" "fmt" + "github.com/privapps/github-copilot-svcs/pkg/transform" "os" "time" - "strings" ) // Command constants to avoid goconst errors @@ -65,7 +66,6 @@ func RunCommand(command string, args []string, version string) error { // Check for flags jsonOutput := len(args) >= 1 && args[0] == "--json" - switch command { case cmdAuth: return handleAuth() @@ -114,7 +114,7 @@ func handleAuth() error { func handleStatusWithFormat(jsonOutput bool) error { cfg, err := LoadConfig() if err != nil { - if strings.Contains(err.Error(), "either github_token or copilot_token must be provided") { + if errors.Is(err, ErrMissingTokens) { fmt.Println("Not authenticated. Run 'auth' to authenticate.") return nil } @@ -215,7 +215,7 @@ func printStatusText(cfg *Config) error { func handleConfig() error { cfg, err := LoadConfig() if err != nil { - if strings.Contains(err.Error(), "either github_token or copilot_token must be provided") { + if errors.Is(err, ErrMissingTokens) { fmt.Println("Not authenticated. Run 'auth' to authenticate.") return nil } @@ -242,7 +242,6 @@ func handleConfig() error { return nil } - func getCurrentTime() int64 { return time.Now().Unix() } @@ -250,7 +249,7 @@ func getCurrentTime() int64 { func handleRun() error { cfg, err := LoadConfig() if err != nil { - if strings.Contains(err.Error(), "either github_token or copilot_token must be provided") { + if errors.Is(err, ErrMissingTokens) { if authErr := handleAuth(); authErr != nil { return fmt.Errorf("authentication failed: %v", authErr) } @@ -280,7 +279,7 @@ func handleRun() error { func handleModels() error { cfg, err := LoadConfig() if err != nil { - if strings.Contains(err.Error(), "either github_token or copilot_token must be provided") { + if errors.Is(err, ErrMissingTokens) { fmt.Println("Not authenticated. Run 'auth' to authenticate.") return nil } @@ -308,18 +307,47 @@ func handleModels() error { return nil } - fmt.Printf("Available models (%d total):\n", len(modelList.Data)) - for _, model := range modelList.Data { + filtered := modelList.Data + var unknown []string + filteredMsg := "" + if len(cfg.AllowedModels) > 0 { + allowedSet := make(map[string]struct{}, len(cfg.AllowedModels)) + for _, name := range cfg.AllowedModels { + allowedSet[name] = struct{}{} + } + var tmp []transform.Model + foundSet := make(map[string]struct{}) + for _, model := range filtered { + if _, ok := allowedSet[model.ID]; ok { + tmp = append(tmp, model) + foundSet[model.ID] = struct{}{} + } + } + for k := range allowedSet { + if _, ok := foundSet[k]; !ok { + unknown = append(unknown, k) + } + } + filtered = tmp + filteredMsg = "NOTE: The model list is filtered by allowed_models in config." + if len(unknown) > 0 { + fmt.Printf("WARNING: The following allowed_models were not found and are ignored: %v\n", unknown) + } + } + fmt.Printf("Available models (%d shown):\n", len(filtered)) + for _, model := range filtered { fmt.Printf(" - %s (%s)\n", model.ID, model.OwnedBy) } - + if filteredMsg != "" { + fmt.Println(filteredMsg) + } return nil } func handleRefresh() error { cfg, err := LoadConfig() if err != nil { - if strings.Contains(err.Error(), "either github_token or copilot_token must be provided") { + if errors.Is(err, ErrMissingTokens) { fmt.Println("Not authenticated. Run 'auth' to authenticate.") return nil } diff --git a/internal/cli_test.go b/internal/cli_test.go index 7cad384..b3d3c9a 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -25,4 +25,4 @@ func TestPrintUsage(t *testing.T) { if len(output) == 0 { t.Error("PrintUsage did not print anything") } -} \ No newline at end of file +} diff --git a/internal/config.go b/internal/config.go index ecf0637..5e338d6 100644 --- a/internal/config.go +++ b/internal/config.go @@ -2,6 +2,7 @@ package internal import ( "encoding/json" + "errors" "fmt" "os" "os/user" @@ -49,11 +50,12 @@ const ( // Config represents the application configuration type Config struct { - Port int `json:"port"` - GitHubToken string `json:"github_token"` - CopilotToken string `json:"copilot_token"` - ExpiresAt int64 `json:"expires_at"` - RefreshIn int64 `json:"refresh_in"` + Port int `json:"port"` + GitHubToken string `json:"github_token"` + CopilotToken string `json:"copilot_token"` + ExpiresAt int64 `json:"expires_at"` + RefreshIn int64 `json:"refresh_in"` + AllowedModels []string `json:"allowed_models"` // HTTP Headers configuration Headers struct { @@ -146,17 +148,7 @@ func LoadConfig(skipTokenValidation ...bool) (*Config, error) { // Validate configuration skip := len(skipTokenValidation) > 0 && skipTokenValidation[0] if skip { - // Validate everything except tokens - if err := cfg.validatePort(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) - } - if err := cfg.validateTimeouts(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) - } - if err := cfg.validateHeaders(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) - } - if err := cfg.validateCORS(); err != nil { + if err := cfg.validateCore(); err != nil { return nil, fmt.Errorf("configuration validation failed: %w", err) } } else { @@ -263,7 +255,7 @@ func (c *Config) validatePort() error { func (c *Config) validateTokens() error { if c.GitHubToken == "" && c.CopilotToken == "" { - return NewValidationError("github_token", "", "either github_token or copilot_token must be provided", nil) + return ErrMissingTokens } return nil } @@ -446,3 +438,28 @@ func (c *Config) SaveConfig(pathOverride ...string) error { }() return json.NewEncoder(f).Encode(c) } + +// UnmarshalConfig is a helper for direct config JSON parsing in tests +func UnmarshalConfig(data []byte, cfg *Config) error { + return json.Unmarshal(data, cfg) +} + +// ErrMissingTokens is returned when neither github_token nor copilot_token are present in configuration. +var ErrMissingTokens = errors.New("missing github_token or copilot_token") + +// validateCore validates config without token validation +func (c *Config) validateCore() error { + if err := c.validatePort(); err != nil { + return err + } + if err := c.validateTimeouts(); err != nil { + return err + } + if err := c.validateHeaders(); err != nil { + return err + } + if err := c.validateCORS(); err != nil { + return err + } + return nil +} diff --git a/internal/config_test.go b/internal/config_test.go index 4cd47ca..6b33ff7 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -66,6 +66,9 @@ func TestConfigValidation(t *testing.T) { if err == nil { t.Error("Expected missing tokens to fail validation") } + if !internalerrorsIs(err, internal.ErrMissingTokens) { + t.Errorf("Expected ErrMissingTokens, got %v", err) + } }) t.Run("valid with copilot token only", func(t *testing.T) { @@ -264,3 +267,53 @@ func TestSetDefaultValues(t *testing.T) { } }) } +func TestAllowedModelsConfig(t *testing.T) { + t.Run("loads allowed_models and respects null behavior", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + } + // Should default (nil) when not set + if cfg.AllowedModels != nil { + t.Errorf("Expected AllowedModels nil, got %v", cfg.AllowedModels) + } + cfg.AllowedModels = []string{"gpt-4o", "claude-3.7-sonnet"} + // Simulate allowed + allowed := func(model string) bool { + for _, m := range cfg.AllowedModels { + if m == model { + return true + } + } + return false + } + if !allowed("gpt-4o") || !allowed("claude-3.7-sonnet") { + t.Errorf("Known allowed models not accepted") + } + if allowed("bad-model") { + t.Errorf("Unexpected model allowed") + } + }) + t.Run("config JSON parsing includes allowed_models", func(t *testing.T) { + jsonCfg := []byte(`{"port":8081, "allowed_models": ["foo", "bar"]}`) + var cfg internal.Config + if err := internal.UnmarshalConfig(jsonCfg, &cfg); err != nil { + t.Fatalf("Failed to decode allowed_models config: %v", err) + } + if len(cfg.AllowedModels) != 2 || cfg.AllowedModels[0] != "foo" || cfg.AllowedModels[1] != "bar" { + t.Errorf("Config parsing error for allowed_models: %#v", cfg.AllowedModels) + } + }) +} +func internalerrorsIs(err, target error) bool { + // Handle errors.Is for wrapped errors in Go 1.13+, separate helper avoids import cycle + if err == nil { + return false + } + if err == target { + return true + } + if unwrapper, ok := err.(interface{ Unwrap() error }); ok { + return internalerrorsIs(unwrapper.Unwrap(), target) + } + return false +} diff --git a/internal/errors.go b/internal/errors.go index 4c49a3e..6028701 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -198,4 +198,4 @@ func IsValidationError(err error) bool { func IsProxyError(err error) bool { _, ok := err.(*ProxyError) return ok -} \ No newline at end of file +} diff --git a/internal/errors_test.go b/internal/errors_test.go index 7e22cad..fc66f4f 100644 --- a/internal/errors_test.go +++ b/internal/errors_test.go @@ -194,4 +194,4 @@ func (m *mockResponseWriter) Write(b []byte) (int, error) { func (m *mockResponseWriter) WriteHeader(statusCode int) { m.status = statusCode -} \ No newline at end of file +} diff --git a/internal/health.go b/internal/health.go index 9176cd5..2e2b1c4 100644 --- a/internal/health.go +++ b/internal/health.go @@ -26,9 +26,9 @@ type HealthStatus string const ( // StatusHealthy indicates the service is healthy. - StatusHealthy HealthStatus = "healthy" + StatusHealthy HealthStatus = "healthy" // StatusDegraded indicates the service is degraded. - StatusDegraded HealthStatus = "degraded" + StatusDegraded HealthStatus = "degraded" // StatusUnhealthy indicates the service is unhealthy. StatusUnhealthy HealthStatus = "unhealthy" ) diff --git a/internal/logger.go b/internal/logger.go index ac91b8d..d57e8aa 100644 --- a/internal/logger.go +++ b/internal/logger.go @@ -2,10 +2,10 @@ package internal import ( "context" + "fmt" "log/slog" "os" "strings" - "fmt" "time" ) @@ -44,10 +44,9 @@ func (h *DenseTextHandler) Handle(_ context.Context, r slog.Record) error { // WithAttrs returns the handler unchanged (attrs unused). func (h *DenseTextHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } -// WithGroup returns the handler unchanged (name unused). -func (h *DenseTextHandler) WithGroup(_ string) slog.Handler { return h } - +// WithGroup returns the handler unchanged (name unused). +func (h *DenseTextHandler) WithGroup(_ string) slog.Handler { return h } const ( defaultLogLevel = "info" diff --git a/internal/middleware.go b/internal/middleware.go index 2154a27..fec18cf 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -4,6 +4,7 @@ package internal import ( "bufio" "bytes" + "encoding/json" "io" "net" "net/http" @@ -78,15 +79,38 @@ func LoggingMiddleware(next http.Handler) http.Handler { r.Body = io.NopCloser(bytes.NewBuffer(requestBody)) } + // Attempt to extract model field (if JSON body present and small enough) + modelName := "" + if len(requestBody) > 0 { + var tmp struct { + Model string `json:"model"` + } + if err := json.Unmarshal(requestBody, &tmp); err == nil && tmp.Model != "" { + modelName = tmp.Model + } + } + // Log request - Info("HTTP Request", - "method", r.Method, - "url", r.URL.String(), - "remote_addr", getClientIP(r), - "user_agent", r.UserAgent(), - "content_length", r.ContentLength, - "has_body", len(requestBody) > 0, - ) + if modelName != "" { + Info("HTTP Request", + "method", r.Method, + "url", r.URL.String(), + "model", modelName, + "remote_addr", getClientIP(r), + "user_agent", r.UserAgent(), + "content_length", r.ContentLength, + "has_body", len(requestBody) > 0, + ) + } else { + Info("HTTP Request", + "method", r.Method, + "url", r.URL.String(), + "remote_addr", getClientIP(r), + "user_agent", r.UserAgent(), + "content_length", r.ContentLength, + "has_body", len(requestBody) > 0, + ) + } // Process request next.ServeHTTP(lrw, r) @@ -230,4 +254,4 @@ func containsOrigin(origins []string, origin string) bool { } } return false -} \ No newline at end of file +} diff --git a/internal/models.go b/internal/models.go index f6c81f9..330f6af 100644 --- a/internal/models.go +++ b/internal/models.go @@ -36,10 +36,10 @@ func FetchFromModelsDev(httpClient *http.Client) (*transform.ModelList, error) { return nil, err } defer func() { - if err := resp.Body.Close(); err != nil { - Warn("Error closing response body", "error", err) - } -}() + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return nil, NewNetworkError("fetch_models", "https://models.dev/api.json", fmt.Sprintf("API returned HTTP %d", resp.StatusCode), nil) @@ -78,6 +78,7 @@ func FetchFromModelsDev(httpClient *http.Client) (*transform.ModelList, error) { Object: "model", Created: time.Now().Unix(), OwnedBy: ownedBy, + APIType: apiTypeForModel(modelID), }) } @@ -87,25 +88,58 @@ func FetchFromModelsDev(httpClient *http.Client) (*transform.ModelList, error) { }, nil } -// GetDefault returns a default list of models based on actual models.dev GitHub Copilot entries +// apiTypeForModel returns the API endpoint type for a model. +// Models are divided into two categories based on testing: +// - "chat_completions": Use /v1/chat/completions (OpenAI Chat Completions API) +// - "responses": Use /v1/responses (OpenAI Responses API) +func apiTypeForModel(modelID string) string { + responsesModels := map[string]bool{ + "gpt-5.3-codex": true, + "gpt-5.4-mini": true, + "gpt-5.6-luna": true, + "gpt-5.6-sol": true, + "gpt-5.6-terra": true, + } + if responsesModels[modelID] { + return "responses" + } + return "chat_completions" +} + +// GetDefault returns a default list of models based on actual GitHub Copilot entries. func GetDefault() []transform.Model { - return []transform.Model{ - // GitHub Copilot (OpenAI-compatible) - {ID: "gpt-4o", Object: "model", Created: time.Now().Unix(), OwnedBy: "openai"}, - {ID: "gpt-4.1", Object: "model", Created: time.Now().Unix(), OwnedBy: "openai"}, - {ID: "o3", Object: "model", Created: time.Now().Unix(), OwnedBy: "openai"}, - {ID: "o3-mini", Object: "model", Created: time.Now().Unix(), OwnedBy: "openai"}, - {ID: "o4-mini", Object: "model", Created: time.Now().Unix(), OwnedBy: "openai"}, - // Claude (Anthropic) - {ID: "claude-3.5-sonnet", Object: "model", Created: time.Now().Unix(), OwnedBy: "anthropic"}, - {ID: "claude-3.7-sonnet", Object: "model", Created: time.Now().Unix(), OwnedBy: "anthropic"}, - {ID: "claude-3.7-sonnet-thought", Object: "model", Created: time.Now().Unix(), OwnedBy: "anthropic"}, - {ID: "claude-opus-4", Object: "model", Created: time.Now().Unix(), OwnedBy: "anthropic"}, - {ID: "claude-sonnet-4", Object: "model", Created: time.Now().Unix(), OwnedBy: "anthropic"}, - // Gemini (Google) - {ID: "gemini-2.5-pro", Object: "model", Created: time.Now().Unix(), OwnedBy: "google"}, - {ID: "gemini-2.0-flash-001", Object: "model", Created: time.Now().Unix(), OwnedBy: "google"}, + now := time.Now().Unix() + entries := []struct { + id string + ownedBy string + apiType string + }{ + // Chat Completions models + {"gpt-4o", "openai", "chat_completions"}, + {"gpt-4.1", "openai", "chat_completions"}, + {"claude-haiku-4.5", "anthropic", "chat_completions"}, + {"claude-sonnet-5", "anthropic", "chat_completions"}, + {"claude-opus-4.8", "anthropic", "chat_completions"}, + {"gemini-3.5-flash", "google", "chat_completions"}, + {"gemini-3.1-pro-preview", "google", "chat_completions"}, + // Responses API models + {"gpt-5.3-codex", "openai", "responses"}, + {"gpt-5.4-mini", "openai", "responses"}, + {"gpt-5.6-luna", "openai", "responses"}, + {"gpt-5.6-sol", "openai", "responses"}, + {"gpt-5.6-terra", "openai", "responses"}, } + models := make([]transform.Model, len(entries)) + for i, e := range entries { + models[i] = transform.Model{ + ID: e.id, + Object: "model", + Created: now, + OwnedBy: e.ownedBy, + APIType: e.apiType, + } + } + return models } // containsAny checks if text contains any of the substrings @@ -185,10 +219,36 @@ func (s *ModelsService) Handler() http.HandlerFunc { }) modelList := result.(*transform.ModelList) - Debug("Returning models", "count", len(modelList.Data)) - + // Filter if allowed_models is set in config + cfg, cfgErr := LoadConfig(true) + filtered := modelList.Data + filteredMsg := "" + if cfgErr == nil && cfg.AllowedModels != nil && len(cfg.AllowedModels) > 0 { + allowedSet := make(map[string]struct{}, len(cfg.AllowedModels)) + for _, name := range cfg.AllowedModels { + allowedSet[name] = struct{}{} + } + var modelsFiltered []transform.Model + for _, m := range filtered { + if _, ok := allowedSet[m.ID]; ok { + modelsFiltered = append(modelsFiltered, m) + } + } + filtered = modelsFiltered + filteredMsg = "(filtered by allowed_models from config)" + } + resp := struct { + Object string `json:"object"` + Data []transform.Model `json:"data"` + Filtered string `json:"note,omitempty"` + }{ + Object: "list", + Data: filtered, + Filtered: filteredMsg, + } + Debug("Returning models", "count", len(filtered)) w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(modelList); err != nil { + if err := json.NewEncoder(w).Encode(resp); err != nil { Error("Error encoding models response", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } diff --git a/internal/models_test.go b/internal/models_test.go index b4d927a..d099745 100644 --- a/internal/models_test.go +++ b/internal/models_test.go @@ -65,18 +65,27 @@ func TestGetDefault(t *testing.T) { } // Verify structure of default models - expectedModels := map[string]string{ - "gpt-4o": "openai", - "claude-3.5-sonnet": "anthropic", - "gemini-2.5-pro": "google", - "claude-opus-4": "anthropic", - "o3": "openai", - "gemini-2.0-flash-001": "google", - } - - modelMap := make(map[string]string) + expectedModels := map[string]struct { + owner string + apiType string + }{ + "gpt-4o": {"openai", "chat_completions"}, + "gpt-4.1": {"openai", "chat_completions"}, + "claude-haiku-4.5": {"anthropic", "chat_completions"}, + "claude-sonnet-5": {"anthropic", "chat_completions"}, + "claude-opus-4.8": {"anthropic", "chat_completions"}, + "gemini-3.5-flash": {"google", "chat_completions"}, + "gemini-3.1-pro-preview": {"google", "chat_completions"}, + "gpt-5.3-codex": {"openai", "responses"}, + "gpt-5.4-mini": {"openai", "responses"}, + "gpt-5.6-luna": {"openai", "responses"}, + "gpt-5.6-sol": {"openai", "responses"}, + "gpt-5.6-terra": {"openai", "responses"}, + } + + modelMap := make(map[string]transform.Model) for _, model := range models { - modelMap[model.ID] = model.OwnedBy + modelMap[model.ID] = model // Verify model structure if model.Object != "model" { @@ -85,14 +94,23 @@ func TestGetDefault(t *testing.T) { if model.Created == 0 { t.Error("Expected model created timestamp to be set") } + if model.APIType == "" { + t.Errorf("Model '%s': Expected non-empty APIType", model.ID) + } } // Check that expected models are present - for expectedID, expectedOwner := range expectedModels { - if owner, exists := modelMap[expectedID]; !exists { + for expectedID, expected := range expectedModels { + model, exists := modelMap[expectedID] + if !exists { t.Errorf("Expected model '%s' not found in default models", expectedID) - } else if owner != expectedOwner { - t.Errorf("Expected model '%s' to be owned by '%s', got '%s'", expectedID, expectedOwner, owner) + continue + } + if model.OwnedBy != expected.owner { + t.Errorf("Model '%s': Expected owner '%s', got '%s'", expectedID, expected.owner, model.OwnedBy) + } + if model.APIType != expected.apiType { + t.Errorf("Model '%s': Expected api_type '%s', got '%s'", expectedID, expected.apiType, model.APIType) } } } @@ -289,6 +307,9 @@ func TestModelsServiceHandler_ReturnsModelsSuccessfully(t *testing.T) { if model.OwnedBy == "" { t.Errorf("Model %d: Expected non-empty OwnedBy", i) } + if model.APIType == "" { + t.Errorf("Model %d: Expected non-empty APIType", i) + } } } @@ -435,12 +456,13 @@ func TestModelOwnershipDetection(t *testing.T) { models := internal.GetDefault() ownershipTests := map[string]string{ - "gpt-4o": "openai", - "claude-3.5-sonnet": "anthropic", - "gemini-2.5-pro": "google", - "o3": "openai", - "claude-opus-4": "anthropic", - "gemini-2.0-flash-001": "google", + "gpt-4o": "openai", + "gpt-4.1": "openai", + "claude-haiku-4.5": "anthropic", + "claude-sonnet-5": "anthropic", + "gemini-3.5-flash": "google", + "gpt-5.6-luna": "openai", + "gpt-5.6-sol": "openai", } for _, model := range models { @@ -472,6 +494,20 @@ func TestModelTimestamps(t *testing.T) { } } +func TestModelAPIType(t *testing.T) { + models := internal.GetDefault() + + for _, model := range models { + switch model.APIType { + case "chat_completions", "responses": + // valid + default: + t.Errorf("Model '%s': Expected api_type to be 'chat_completions' or 'responses', got '%s'", + model.ID, model.APIType) + } + } +} + // CountingCache implements CoalescingCacheInterface with execution counting type CountingCache struct { executeCount int diff --git a/internal/proxy.go b/internal/proxy.go index 8549682..e94a3d7 100644 --- a/internal/proxy.go +++ b/internal/proxy.go @@ -15,11 +15,35 @@ import ( "time" ) -const ( - copilotAPIBase = "https://api.githubcopilot.com" - chatCompletionsPath = "/chat/completions" +var ( + copilotAPIBase = "https://api.githubcopilot.com" + copilotAPIBaseMu sync.RWMutex +) +var chatCompletionsPath = "/chat/completions" +var responsesPath = "/responses" + +// SetCopilotAPIBase overrides the upstream API base URL for testing. +func SetCopilotAPIBase(base string) { + copilotAPIBaseMu.Lock() + defer copilotAPIBaseMu.Unlock() + copilotAPIBase = base +} + +// ResetCopilotAPIBase restores the default upstream API base URL. +func ResetCopilotAPIBase() { + copilotAPIBaseMu.Lock() + defer copilotAPIBaseMu.Unlock() + copilotAPIBase = "https://api.githubcopilot.com" +} + +// GetCopilotAPIBase returns the current upstream API base URL. +func GetCopilotAPIBase() string { + copilotAPIBaseMu.RLock() + defer copilotAPIBaseMu.RUnlock() + return copilotAPIBase +} - // Retry configuration for chat completions +const ( maxChatRetries = 3 baseChatRetryDelay = 1 // seconds @@ -38,9 +62,9 @@ const ( const ( // ProxyCBStateClosed indicates the circuit breaker is closed. - ProxyCBStateClosed = 0 + ProxyCBStateClosed = 0 // ProxyCBStateOpen indicates the circuit breaker is open. - ProxyCBStateOpen = 1 + ProxyCBStateOpen = 1 // ProxyCBStateHalfOpen indicates the circuit breaker is half-open. ProxyCBStateHalfOpen = 2 ) @@ -323,12 +347,27 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW return fmt.Errorf("bad request: empty request body") } - // Strict JSON validation before authentication - var js json.RawMessage - if jsonErr := json.Unmarshal(body, &js); jsonErr != nil { + var input struct { + Model string `json:"model"` + } + if jsonErr := json.Unmarshal(body, &input); jsonErr != nil { return fmt.Errorf("bad request: invalid JSON: %w", jsonErr) } + // AllowedModels validation + if len(s.config.AllowedModels) > 0 { + allowed := false + for _, m := range s.config.AllowedModels { + if input.Model == m { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("bad request: model '%s' is not allowed by allowed_models in config", input.Model) + } + } + // Ensure we have a valid token before making the request if tokenErr := s.authService.EnsureValidToken(s.config); tokenErr != nil { Error("Failed to ensure valid token", "error", tokenErr) @@ -336,7 +375,16 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW } // Create new request to GitHub Copilot - targetURL := copilotAPIBase + chatCompletionsPath + var targetURL string + base := GetCopilotAPIBase() + switch r.URL.Path { + case "/v1/chat/completions": + targetURL = base + chatCompletionsPath + case "/v1/responses": + targetURL = base + responsesPath + default: + return fmt.Errorf("unsupported proxy path: %s", r.URL.Path) + } Debug("Sending request to target", "url", targetURL, "body_length", len(body)) req, err := http.NewRequestWithContext(ctx, r.Method, targetURL, bytes.NewBuffer(body)) @@ -346,9 +394,21 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW } // Set headers + // Forward content/negotiation headers from client if present; use defaults if missing + headersToProxy := []string{"Content-Type", "Accept", "Accept-Encoding", "TE"} + defaults := map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + } + for _, h := range headersToProxy { + if v := r.Header.Get(h); v != "" { + req.Header.Set(h, v) + } else if def, ok := defaults[h]; ok { + req.Header.Set(h, def) + } + } + req.Header.Set("Authorization", "Bearer "+s.config.CopilotToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", s.config.Headers.UserAgent) req.Header.Set("Editor-Version", s.config.Headers.EditorVersion) req.Header.Set("Editor-Plugin-Version", s.config.Headers.EditorPluginVersion) @@ -363,10 +423,10 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW return NewNetworkError("proxy_request", targetURL, "failed to complete request after retries", err) } defer func() { - if err := resp.Body.Close(); err != nil { - Warn("Error closing response body", "error", err) - } -}() + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() // Update circuit breaker based on response if resp.StatusCode < statusCodeServerError { diff --git a/internal/server.go b/internal/server.go index 020fcad..0610bf0 100644 --- a/internal/server.go +++ b/internal/server.go @@ -88,6 +88,7 @@ func CreateHTTPClient(cfg *Config) *http.Client { return &http.Client{ Timeout: time.Duration(cfg.Timeouts.HTTPClient) * time.Second, Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost, IdleConnTimeout: time.Duration(cfg.Timeouts.IdleConnTimeout) * time.Second, @@ -120,6 +121,7 @@ func NewServer(cfg *Config, httpClient *http.Client) *Server { mux := http.NewServeMux() mux.HandleFunc("/v1/models", modelsService.Handler()) mux.HandleFunc("/v1/chat/completions", proxyService.Handler()) + mux.HandleFunc("/v1/responses", proxyService.Handler()) mux.HandleFunc("/health", healthChecker.Handler()) // Add pprof endpoints for profiling @@ -174,6 +176,7 @@ func (s *Server) Start() error { fmt.Printf("Endpoints:\n") fmt.Printf(" - Models: http://localhost:%d/v1/models\n", port) fmt.Printf(" - Chat: http://localhost:%d/v1/chat/completions\n", port) + fmt.Printf(" - Responses: http://localhost:%d/v1/responses\n", port) fmt.Printf(" - Health: http://localhost:%d/health\n", port) if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/pkg/transform/transform.go b/pkg/transform/transform.go index 47e4567..ec77453 100644 --- a/pkg/transform/transform.go +++ b/pkg/transform/transform.go @@ -1,6 +1,8 @@ // Package transform provides OpenAI-compatible request/response structures for github-copilot-svcs. package transform +import "encoding/json" + // ChatCompletionRequest ... type ChatCompletionRequest struct { Model string `json:"model"` @@ -10,10 +12,26 @@ type ChatCompletionRequest struct { Stream bool `json:"stream,omitempty"` } -// ChatCompletionMessage ... +// ChatCompletionMessage supports both text-only content (string) and multi-part content (array) +// for vision/image requests. Content can be either: +// - A string for simple text messages +// - An array of ContentPart objects for messages with images type ChatCompletionMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` // Can be string or []ContentPart +} + +// ContentPart represents a part of a multi-part message (text or image) +type ContentPart struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // For type="text" + ImageURL *ImageURL `json:"image_url,omitempty"` // For type="image_url" +} + +// ImageURL contains the image URL (can be http(s):// or data: URI with base64) +type ImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` // "auto", "low", or "high" } // ChatCompletionResponse ... @@ -52,4 +70,5 @@ type Model struct { Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` -} \ No newline at end of file + APIType string `json:"api_type"` // "chat_completions" or "responses" +} diff --git a/test/integration/api_test.go b/test/integration/api_test.go index aa93ab8..6c43e51 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -1,11 +1,13 @@ package integration_test import ( + "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -240,6 +242,201 @@ func TestChatCompletionsEndpoint(t *testing.T) { } } +// TestHeaderForwardingProxy checks correct forwarding and defaulting of Content-Type, Accept, Accept-Encoding, TE headers +func TestHeaderForwardingProxy(t *testing.T) { + // --- Setup fake upstream server to capture proxied headers --- + var capturedHeaders http.Header + mux := http.NewServeMux() + mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + // Point the proxy at our fake upstream + internal.SetCopilotAPIBase(ts.URL) + defer internal.ResetCopilotAPIBase() + + // Patch config to point upstream base URLs to our fake server + cfg := &internal.Config{ + Port: 0, + CopilotToken: "token", + ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), + AllowedModels: []string{"gpt-4"}, + } + internal.SetDefaultTimeouts(cfg) + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + + httpClient := &http.Client{Transport: &http.Transport{}} + proxy := internal.NewProxyService(cfg, httpClient, internal.NewAuthService(httpClient), internal.NewWorkerPool(1)) + srv := httptest.NewServer(proxy.Handler()) + defer srv.Close() + + cases := []struct { + name string + headers map[string]string + wantExpected map[string]string + }{ + { + name: "all client headers set", + headers: map[string]string{ + "Content-Type": "custom/type", + "Accept": "foo/bar", + "Accept-Encoding": "gzip, deflate", + "TE": "trailers", + }, + wantExpected: map[string]string{ + "Content-Type": "custom/type", + "Accept": "foo/bar", + "Accept-Encoding": "gzip, deflate", + "TE": "trailers", + }, + }, + { + name: "content-type only", + headers: map[string]string{ + "Content-Type": "foo/baz", + }, + wantExpected: map[string]string{ + "Content-Type": "foo/baz", + "Accept": "application/json", + }, + }, + { + name: "accept only", + headers: map[string]string{ + "Accept": "bar/foo", + }, + wantExpected: map[string]string{ + "Content-Type": "application/json", + "Accept": "bar/foo", + }, + }, + { + name: "neither set (default both)", + headers: map[string]string{}, + wantExpected: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + capturedHeaders = nil // Reset + jsonBody := `{"model":"gpt-4","messages":[{"role":"user","content":"test"}]}` + client := &http.Client{} + req, err := http.NewRequest("POST", srv.URL+"/v1/chat/completions", strings.NewReader(jsonBody)) + if err != nil { + t.Fatalf("new req err: %v", err) + } + for k, v := range tc.headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("proxy req failed: %v", err) + } + io.ReadAll(resp.Body) + resp.Body.Close() + + for wantKey, wantVal := range tc.wantExpected { + got := capturedHeaders.Get(wantKey) + if got != wantVal { + t.Errorf("expected header %q to be %q, got %q. All headers: %+v", wantKey, wantVal, got, capturedHeaders) + } + } + // TE should not be forwarded unless explicitly set by client. + // Note: Accept-Encoding may be added automatically by Go's HTTP client. + if _, ok := tc.headers["TE"]; !ok { + if capturedHeaders.Get("TE") != "" { + t.Errorf("expected header %q absent, got %q. All headers: %+v", "TE", capturedHeaders.Get("TE"), capturedHeaders) + } + } + }) + } +} + +// TestResponsesEndpoint tests the /v1/responses endpoint for GPT-5.x models +func TestResponsesEndpoint(t *testing.T) { + tests := []struct { + name string + method string + endpoint string + body string + expectedStatus int + contentType string + }{ + { + name: "responses with empty body", + method: "POST", + endpoint: "/v1/responses", + body: "", + expectedStatus: http.StatusBadRequest, + contentType: "application/json", + }, + { + name: "responses with invalid JSON", + method: "POST", + endpoint: "/v1/responses", + body: `{"invalid": json}`, + expectedStatus: http.StatusBadRequest, + contentType: "application/json", + }, + { + name: "responses with wrong method", + method: "GET", + endpoint: "/v1/responses", + body: "", + expectedStatus: http.StatusMethodNotAllowed, + contentType: "application/json", + }, + { + name: "responses with basic valid request", + method: "POST", + endpoint: "/v1/responses", + body: `{"model":"gpt-5.6-luna","input":"test","max_output_tokens":50}`, + expectedStatus: http.StatusUnauthorized, // Should be 401 if auth is missing + contentType: "application/json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body io.Reader + if tt.body != "" { + body = strings.NewReader(tt.body) + } + + req, err := http.NewRequest(tt.method, baseURL+tt.endpoint, body) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + if tt.contentType != "" { + req.Header.Set("Content-Type", tt.contentType) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tt.expectedStatus { + respBody, _ := io.ReadAll(resp.Body) + t.Errorf("Expected status %d, got %d. Response: %s", tt.expectedStatus, resp.StatusCode, string(respBody)) + } + }) + } +} + func TestCORSHeaders(t *testing.T) { tests := []struct { name string @@ -486,3 +683,188 @@ func waitForServer(baseURL string, timeout time.Duration) bool { } return false } + +// TestVisionSupport tests that the proxy correctly handles vision/image requests +func TestVisionSupport(t *testing.T) { + // Create a small 1x1 transparent PNG image for testing + pngData, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + imageDataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngData) + + tests := []struct { + name string + payload string + expectedStatus int + description string + }{ + { + name: "vision request with image_url", + payload: fmt.Sprintf(`{ +"model": "gpt-4o", +"messages": [{ +"role": "user", +"content": [ +{"type": "text", "text": "Describe this image"}, +{"type": "image_url", "image_url": {"url": "%s"}} +] +}], +"max_tokens": 100 +}`, imageDataURL), + expectedStatus: http.StatusUnauthorized, // Will fail auth, but should accept the payload structure + description: "Multi-part content with image should be accepted", + }, + { + name: "vision request with base64 image", + payload: fmt.Sprintf(`{ +"model": "gpt-4o", +"messages": [{ +"role": "user", +"content": [ +{"type": "text", "text": "What's in this image?"}, +{"type": "image_url", "image_url": {"url": "%s", "detail": "high"}} +] +}], +"max_tokens": 200 +}`, imageDataURL), + expectedStatus: http.StatusUnauthorized, + description: "Image with detail parameter should be accepted", + }, + { + name: "text-only request still works", + payload: `{ +"model": "gpt-4o", +"messages": [{ +"role": "user", +"content": "Hello" +}], +"max_tokens": 50 +}`, + expectedStatus: http.StatusUnauthorized, + description: "Backward compatibility: text-only content should still work", + }, + { + name: "mixed text and vision in same conversation", + payload: fmt.Sprintf(`{ +"model": "gpt-4o", +"messages": [ +{ +"role": "user", +"content": "Hello" +}, +{ +"role": "assistant", +"content": "Hi! How can I help?" +}, +{ +"role": "user", +"content": [ +{"type": "text", "text": "Look at this"}, +{"type": "image_url", "image_url": {"url": "%s"}} +] +} +], +"max_tokens": 150 +}`, imageDataURL), + expectedStatus: http.StatusUnauthorized, + description: "Mixed text and vision messages should be accepted", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(tt.payload)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + // We expect 401 because we don't have auth in tests + // But the important part is that the request is not rejected as "bad request" + if resp.StatusCode != tt.expectedStatus { + body, _ := io.ReadAll(resp.Body) + t.Errorf("%s: Expected status %d, got %d. Response: %s", + tt.description, tt.expectedStatus, resp.StatusCode, string(body)) + } + + // If we got a 400, it means the payload structure was rejected + if resp.StatusCode == http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("%s: Vision payload was rejected as bad request. Response: %s", + tt.description, string(body)) + } + }) + } +} + +// TestVisionPayloadValidation ensures vision payloads pass JSON validation +func TestVisionPayloadValidation(t *testing.T) { + pngData, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + imageDataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngData) + + tests := []struct { + name string + payload string + shouldPass bool + description string + }{ + { + name: "valid vision payload", + payload: fmt.Sprintf(`{ +"model": "gpt-4o", +"messages": [{ +"role": "user", +"content": [ +{"type": "text", "text": "test"}, +{"type": "image_url", "image_url": {"url": "%s"}} +] +}] +}`, imageDataURL), + shouldPass: true, + description: "Valid vision payload should pass validation", + }, + { + name: "missing model field", + payload: `{"messages": [{"role": "user", "content": "test"}]}`, + shouldPass: true, + description: "Missing model field results in empty model", + }, + { + name: "invalid json", + payload: `{"model": "gpt-4o", invalid}`, + shouldPass: false, + description: "Invalid JSON should fail", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(tt.payload)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + isBadRequest := resp.StatusCode == http.StatusBadRequest + if tt.shouldPass && isBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("%s: Expected to pass, got 400. Response: %s", tt.description, string(body)) + } + if !tt.shouldPass && !isBadRequest { + t.Errorf("%s: Expected to fail validation, got status %d", tt.description, resp.StatusCode) + } + }) + } +} diff --git a/test/testutils/helpers.go b/test/testutils/helpers.go index 2396274..ceced26 100644 --- a/test/testutils/helpers.go +++ b/test/testutils/helpers.go @@ -57,8 +57,8 @@ func SetupTestDir(t *testing.T) string { t.Cleanup(func() { if err := os.RemoveAll(dir); err != nil { - panic(err) -} + panic(err) + } }) return dir diff --git a/test_vision_proxy.sh b/test_vision_proxy.sh new file mode 100755 index 0000000..4b2ea76 --- /dev/null +++ b/test_vision_proxy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# Test vision capabilities through the proxy +# Usage: ./test_vision_proxy.sh [image_path] + +set -euo pipefail + +PROXY_URL="${PROXY_URL:-http://localhost:8081}" +IMAGE_PATH="${1:-dog.jpeg}" +PROMPT="${2:-Describe the attached image in detail.}" + +if [[ ! -f "$IMAGE_PATH" ]]; then + echo "Image '$IMAGE_PATH' does not exist." >&2 + exit 1 +fi + +# Get token from config +TOKEN=no_token + +echo "Testing vision through proxy at $PROXY_URL..." +echo "Image: $IMAGE_PATH" +echo "Token: ${TOKEN:0:10}..." + +# Encode image +IMAGE_MIME="$(file --brief --mime-type "$IMAGE_PATH")" +IMAGE_BASE64="$( + python3 - "$IMAGE_PATH" <<'PY' +import base64, sys +with open(sys.argv[1], "rb") as f: + print(base64.b64encode(f.read()).decode("ascii")) +PY +)" + +# Create request payload +REQUEST_FILE="$(mktemp)" +trap 'rm -f "$REQUEST_FILE"' EXIT + +jq -n \ + --arg model "gpt-4o" \ + --arg prompt "$PROMPT" \ + --arg image "data:$IMAGE_MIME;base64,$IMAGE_BASE64" \ + '{ + model: $model, + messages: [ + { + role: "user", + content: [ + {type: "text", text: $prompt}, + {type: "image_url", image_url: {url: $image}} + ] + } + ], + max_tokens: 500 + }' > "$REQUEST_FILE" + +echo "Request payload size: $(stat -f%z "$REQUEST_FILE") bytes" +echo "Sending request..." + +# Send request through proxy (NOT directly to GitHub) +RESPONSE="$(curl -sS -X POST "$PROXY_URL/v1/chat/completions" \ + -H "Content-Type: application/json" \ + --data-binary @"$REQUEST_FILE")" + +echo "" +echo "Response:" +echo "$RESPONSE" | jq . + +# Extract and display just the content +CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // "No content"') +echo "" +echo "=== AI Response ===" +echo "$CONTENT" +echo "" + +# Check for errors +if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then + echo "ERROR in response!" >&2 + exit 1 +fi + +echo "✓ Vision test successful!"