diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..90911d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Build artifacts +github-copilot-svcs +github-copilot-svcs-* +*.exe + +# Go build cache +.go-build-cache + +# Test artifacts +coverage.out +coverage.html + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.log + +# Config files (sensitive) +config.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e78cf34 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,151 @@ +name: CI + +on: + push: + branches: [ dev ] + pull_request: + branches: [ main ] + +permissions: + contents: read + packages: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: | + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.out + coverage.html + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 + + security: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: Run Go Security Checks + run: | + echo "Running Go vet for security analysis..." + go vet ./... + echo "Running go mod verify for dependency integrity..." + go mod verify + echo "Security checks completed successfully" + + build: + runs-on: ubuntu-latest + needs: [test, lint, security] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: Build binary + run: | + go build -ldflags="-s -w -X main.version=ci-${{ github.sha }}" -o github-copilot-svcs ./cmd/github-copilot-svcs + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: github-copilot-svcs-${{ github.sha }} + path: github-copilot-svcs + + docker: + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 267baf9..f5845d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,23 +7,50 @@ on: 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 }} - upload_url: ${{ steps.create_release.outputs.upload_url }} 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.21' + go-version: '1.23' - name: Get next version id: version @@ -59,29 +86,6 @@ jobs: git tag ${{ steps.version.outputs.version }} git push origin ${{ steps.version.outputs.version }} - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - ## Changes in ${{ steps.version.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` - draft: false - prerelease: false - build: needs: release runs-on: ubuntu-latest @@ -109,12 +113,12 @@ 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.21' + go-version: '1.23' - name: Build binary env: @@ -123,7 +127,7 @@ jobs: CGO_ENABLED: 0 run: | BINARY_NAME="github-copilot-svcs-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}" - go build -ldflags="-s -w -X main.version=${{ needs.release.outputs.version }}" -o "$BINARY_NAME" . + go build -ldflags="-s -w -X main.version=${{ needs.release.outputs.version }}" -o "$BINARY_NAME" ./cmd/github-copilot-svcs # Make the binary executable (important for Unix systems) chmod +x "$BINARY_NAME" @@ -135,12 +139,87 @@ jobs: echo "Built and gzipped binary: $GZ_BINARY_NAME" ls -la "$GZ_BINARY_NAME" - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: - upload_url: ${{ needs.release.outputs.upload_url }} - asset_path: ./github-copilot-svcs-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}.gz - asset_name: github-copilot-svcs-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}.gz - asset_content_type: application/gzip + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.version }} + type=semver,pattern={{major}},value=${{ needs.release.outputs.version }} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + 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/.gitignore b/.gitignore index aaadf73..9995062 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,10 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +.github/copilot-instructions.md +.publish.sh +QWEN.md +TODO.md +docs/ +github-copilot-svcs diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..66eb7e5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +version: "2" + +linters: + enable: + - govet + - errcheck + - staticcheck + - ineffassign + - gocritic + - revive + +run: + timeout: 5m + tests: false + concurrency: 4 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file 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 new file mode 100644 index 0000000..1fe2734 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install git and ca-certificates for building +RUN apk add --no-cache git ca-certificates +# Diagnostic: Print Go version in build environment +RUN go version + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +ARG VERSION=docker +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o github-copilot-svcs ./cmd/github-copilot-svcs + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates tzdata wget + +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +# Switch to non-root user +USER appuser +WORKDIR /home/appuser/ + +# Copy the binary from builder +COPY --from=builder /app/github-copilot-svcs . + +# Create config directory for non-root user +RUN mkdir -p /home/appuser/.local/share/github-copilot-svcs + +# Expose the default port +EXPOSE 8081 + +# Health check +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", "start"] diff --git a/LICENSE b/LICENSE index 2296789..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,200 +1,201 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, -and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity granting the License. - -"Legal Entity" shall mean the union of the acting entity and all -other entities that control, are controlled by, or are under common -control with that entity. For the purposes of this definition, -"control" means (i) the power, direct or indirect, to cause the -direction or management of such entity, whether by contract or -otherwise, or (ii) ownership of fifty percent (50%) or more of the -outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity -exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, -including but not limited to software source code, documentation -source, and configuration files. - -"Object" form shall mean any form resulting from mechanical -transformation or translation of a Source form, including but -not limited to compiled object code, generated documentation, -and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or -Object form, made available under the License, as indicated by a -copyright notice that is included in or attached to the work -(which shall not include communications that are reasonably -considered to be ancillary to the License). - -"Derivative Works" shall mean any work, whether in Source or Object -form, that is based upon (or derived from) the Work and for which the -editorial revisions, annotations, elaborations, or other modifications -represent, as a whole, an original work of authorship. For the purposes -of this License, Derivative Works shall not include works that remain -separable from, or merely link (or bind by name) to the interfaces of, -the Work and derivative works thereof. - -"Contributor" shall mean Licensor and any individual or Legal Entity -on behalf of whom a Contribution has been made to Licensor and that -Contribution has been incorporated within the Work. - -"Contribution" shall mean any work of authorship, including -the original version of the Work and any modifications or additions -to that Work or Derivative Works thereof, that is intentionally -submitted to Licensor for inclusion in the Work by the copyright owner -or by an individual or Legal Entity authorized to submit on behalf of -the copyright owner. For the purposes of this definition, "submitted" -means any form of electronic, verbal, or written communication sent -to the Licensor or its representatives, including but not limited to -communication on electronic mailing lists, source code control -systems, and issue tracking systems that are managed by, or on behalf -of, the Licensor for the purpose of discussing and improving the Work, -but excluding communication that is conspicuously marked or otherwise -designated in writing by the copyright owner as "Not a Contribution." - -2. Grant of Copyright License. Subject to the terms and conditions of -this License, each Contributor hereby grants to You a perpetual, -worldwide, non-exclusive, no-charge, royalty-free, irrevocable -copyright license to use, reproduce, modify, distribute, and prepare -Derivative Works of, publicly display, publicly perform, sublicense, -and distribute the Work and such Derivative Works in Source or Object -form. - -3. Grant of Patent License. Subject to the terms and conditions of -this License, each Contributor hereby grants to You a perpetual, -worldwide, non-exclusive, no-charge, royalty-free, irrevocable -(except as stated in this section) patent license to make, have made, -use, offer to sell, sell, import, and otherwise transfer the Work, -where such license applies only to those patent claims licensable -by such Contributor that are necessarily infringed by their -Contribution(s) alone or by combination of their Contribution(s) -with the Work to which such Contribution(s) was submitted. If You -institute patent litigation against any entity (including a -cross-claim or counterclaim in a lawsuit) alleging that the Work -or a Contribution incorporated within the Work constitutes direct -or contributory patent infringement, then any patent licenses -granted to You under this License for that Work shall terminate -as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the -Work or Derivative Works thereof in any medium, with or without -modifications, and in Source or Object form, provided that You -meet the following conditions: - -(a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - -(b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - -(c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, trademark, patent, - attribution and other notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - -(d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - -You may add Your own copyright notice to Your modifications and -may provide additional or different license terms and conditions -for use, reproduction, or distribution of Your modifications, or -for any such Derivative Works as a whole, provided Your use, -reproduction, and distribution of the Work otherwise complies with -the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, -any Contribution intentionally submitted for inclusion in the Work -by You to the Licensor shall be under the terms and conditions of -this License, without any additional terms or conditions. -Notwithstanding the above, nothing herein shall supersede or modify -the terms of any separate license agreement you may have executed -with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade -names, trademarks, service marks, or product names of the Licensor, -except as required for reasonable and customary use in describing the -origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or -agreed to in writing, Licensor provides the Work (and each -Contributor provides its Contributions) on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -implied, including, without limitation, any warranties or conditions -of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A -PARTICULAR PURPOSE. You are solely responsible for determining the -appropriateness of using or redistributing the Work and assume any -risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, -whether in tort (including negligence), contract, or otherwise, -unless required by applicable law (such as deliberate and grossly -negligent acts) or agreed to in writing, shall any Contributor be -liable to You for damages, including any direct, indirect, special, -incidental, or consequential damages of any character arising as a -result of this License or out of the use or inability to use the -Work (including but not limited to damages for loss of goodwill, -work stoppage, computer failure or malfunction, or any and all -other commercial damages or losses), even if such Contributor -has been advised of the possibility of such damages. - -9. Accepting Warranty or Support. You may choose to offer and charge -a fee for, warranty, support, indemnity or other liability obligations -and/or rights consistent with this License. However, in accepting such -obligations, You may act only on Your own behalf and on Your sole -responsibility, not on behalf of any other Contributor, and only if -You agree to indemnify, defend, and hold each Contributor harmless for -any liability incurred by, or claims asserted against, such Contributor -by reason of your accepting any such warranty or support. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - -To apply the Apache License to your work, attach the following -boilerplate notice, with the fields enclosed by brackets "[]" -replaced with your own identifying information. (Don't include -the brackets!) The text should be enclosed in the appropriate -comment syntax for the file format. We also recommend that a -file or class name and description of purpose be included on the -same "license" line as the copyright notice for easier -identification within third-party archives. - -Copyright 2025 GitHub Copilot SVCS Proxy - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 4216cd2..5450d80 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,146 @@ BINARY=github-copilot-svcs -VERSION ?= dev +CMD_PATH=./cmd/github-copilot-svcs +VERSION?=dev -all: build +.PHONY: build test test-unit test-integration test-e2e test-all clean run dev lint fmt vet deps update-deps security mocks docker-build docker-run help test-coverage test-short test-verbose +# Build the binary build: - go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) . + go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) $(CMD_PATH) +# Build for specific OS/ARCH +build-linux-amd64: + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-linux-amd64 $(CMD_PATH) + +build-linux-arm64: + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-linux-arm64 $(CMD_PATH) + +build-darwin-amd64: + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-darwin-amd64 $(CMD_PATH) + +build-darwin-arm64: + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-darwin-arm64 $(CMD_PATH) + +build-windows-amd64: + GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-windows-amd64.exe $(CMD_PATH) + +build-windows-arm64: + GOOS=windows GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY)-windows-arm64.exe $(CMD_PATH) + +# Run the application run: build ./$(BINARY) run -auth: - ./$(BINARY) auth +# Development server with hot reload (requires air: go install github.com/cosmtrek/air@latest) +dev: + air -c .air.toml + +# Run only unit tests +test-unit: + go test -v -race ./internal/... ./pkg/... + +# Run only integration tests +test-integration: + go test -v -race ./test/integration/... + +# Run only e2e tests +test-e2e: + go test -v -race ./test/e2e/... + +# Run all tests +test-all: + go test -v -race ./test/... + +# Default test command (unit tests) +test: test-unit -models: - ./$(BINARY) models +# Test with coverage +test-coverage: + go test -v -race -coverprofile=coverage.out -coverpkg=./internal/...,./cmd/...,./pkg/... ./test/... ./internal/... + go tool cover -html=coverage.out -o coverage.html + go tool cover -func=coverage.out + @echo "Coverage report generated: coverage.html" -config: - ./$(BINARY) config +# Test short (skip integration tests) +test-short: + go test -short -v -race ./test/... +# Clean test artifacts and build files clean: - rm -f $(BINARY) + rm -f $(BINARY) coverage.out coverage.html + go clean -testcache + go mod tidy + +# Run tests with verbose output and show which tests are running +test-verbose: + go test -v -race ./test/... -run . + +# Lint the code (requires golangci-lint) +lint: + golangci-lint run + +# Format the code +fmt: + go fmt ./... + +# Vet the code +vet: + go vet ./... + +# Install dependencies +deps: + go mod download + go mod verify + +# Update dependencies +update-deps: + go get -u ./... + go mod tidy + +# Security check (requires gosec: go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest) +security: + gosec ./... + +# Generate mocks (requires mockery: go install github.com/vektra/mockery/v2@latest) +mocks: + mockery --all --output=test/mocks + +# Docker build +docker-build: + docker build --build-arg VERSION=$(VERSION) -t $(BINARY):$(VERSION) . + +# Docker run +docker-run: + docker run -p 8081:8081 $(BINARY):$(VERSION) + +# Help +help: + @echo "Available targets:" + @echo " build Build the binary" + @echo " build-linux-amd64 Build for Linux amd64" + @echo " build-linux-arm64 Build for Linux arm64" + @echo " build-darwin-amd64 Build for macOS amd64" + @echo " build-darwin-arm64 Build for macOS arm64" + @echo " build-windows-amd64 Build for Windows amd64" + @echo " build-windows-arm64 Build for Windows arm64" + @echo " run Build and run the application" + @echo " dev Run development server with hot reload" + @echo " test Run unit tests (default)" + @echo " test-unit Run only unit tests" + @echo " test-integration Run only integration tests" + @echo " test-e2e Run only e2e tests" + @echo " test-all Run all tests" + @echo " test-coverage Run tests with coverage report" + @echo " test-short Run tests (skip integration tests)" + @echo " test-verbose Run tests with verbose output" + @echo " lint Lint the code" + @echo " fmt Format the code" + @echo " vet Vet the code" + @echo " clean Clean build artifacts and test cache" + @echo " deps Install dependencies" + @echo " update-deps Update dependencies" + @echo " security Run security checks" + @echo " mocks Generate mocks" + @echo " docker-build Build Docker image" + @echo " docker-run Run Docker container" + @echo " help Show this help message" diff --git a/README.md b/README.md index 93701c2..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 @@ -33,6 +40,22 @@ Available platforms: - **macOS**: AMD64 (Intel), ARM64 (Apple Silicon) - **Windows**: AMD64, ARM64 +### Docker Images + +Docker images are automatically built and published to GitHub Container Registry via GitHub Actions: + +```bash +# Pull the latest image +docker pull ghcr.io/privapps/github-copilot-svcs:latest + +# Pull a specific version (example) +docker pull ghcr.io/privapps/github-copilot-svcs:0.0.2 +``` + +Available architectures: +- `linux/amd64` +- `linux/arm64` + ### Automated Releases Releases are automatically created when code is merged to the `main` branch: @@ -68,9 +91,8 @@ This service includes enterprise-grade performance optimizations: ### 📊 Monitoring & Observability - **Profiling Endpoints**: `/debug/pprof/*` for memory, CPU, and goroutine analysis -- **Enhanced Logging**: Circuit breaker state, request coalescing, worker pool metrics, and performance data +- **Enhanced Logging**: Circuit breaker state, request coalescing, and performance data - **Health Monitoring**: Detailed `/health` endpoint for load balancer integration -- **Production Metrics**: Built-in support for operational monitoring and worker pool status ## Quickstart with Makefile @@ -79,12 +101,46 @@ If you have `make` installed, you can build, run, and test the project easily: ```bash make build # Build the binary make run # Start the proxy server -make auth # Authenticate with GitHub Copilot -make models # List available models -make config # Show current configuration +make test # Run unit tests +make test-all # Run all tests +make test-coverage # Generate coverage report make clean # Remove the binary +make lint # Run linting +make fmt # Format code +make vet # Run go vet +make security # Run security analysis +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: + +- `make build-linux-amd64` Build for Linux amd64 +- `make build-linux-arm64` Build for Linux arm64 +- `make build-darwin-amd64` Build for macOS amd64 +- `make build-darwin-arm64` Build for macOS arm64 +- `make build-windows-amd64` Build for Windows amd64 +- `make build-windows-arm64` Build for Windows arm64 + +The output binaries will be named accordingly (e.g., `github-copilot-svcs-windows-arm64.exe`). + ## Installation & Usage ### 1. Build the Application @@ -103,8 +159,6 @@ cp config.example.json ~/.local/share/github-copilot-svcs/config.json ### 3. First Time Setup & Authentication ```bash -make auth -# or manually: ./github-copilot-svcs auth ``` @@ -115,11 +169,19 @@ make run ./github-copilot-svcs run ``` +## Docker Deployment +``` +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:latest +``` + ## CLI Commands | Command | Description | |---------|-------------| -| `run` | Start the proxy server | +| `run` | Run the proxy server (default command) | | `auth` | Authenticate with GitHub Copilot using device flow | | `status` | Show detailed authentication and token status | | `config` | Display current configuration details | @@ -130,10 +192,11 @@ make run ### Enhanced Status Monitoring -The `status` command now provides detailed token information: +The `status` command now provides detailed token information with optional JSON output: ```bash ./github-copilot-svcs status +./github-copilot-svcs status --json # JSON format output ``` Example output: @@ -162,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!"} ], @@ -170,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 @@ -224,6 +300,7 @@ Chat completion requests are automatically retried to handle transient failures: ## Configuration + The configuration is stored in `~/.local/share/github-copilot-svcs/config.json`: ```json @@ -233,6 +310,14 @@ The configuration is stored in `~/.local/share/github-copilot-svcs/config.json`: "copilot_token": "ghu_...", "expires_at": 1720000000, "refresh_in": 1500, + "headers": { + "user_agent": "GitHubCopilotChat/0.29.1", + "editor_version": "vscode/1.102.3", + "editor_plugin_version": "copilot-chat/0.29.1", + "copilot_integration_id": "vscode-chat", + "openai_intent": "conversation-edits", + "x_initiator": "user" + }, "timeouts": { "http_client": 300, "server_read": 30, @@ -248,6 +333,7 @@ The configuration is stored in `~/.local/share/github-copilot-svcs/config.json`: } ``` + ### Configuration Fields - `port`: Server port (default: 8081) @@ -255,6 +341,21 @@ The configuration is stored in `~/.local/share/github-copilot-svcs/config.json`: - `copilot_token`: GitHub Copilot API token - `expires_at`: Unix timestamp when the Copilot token expires - `refresh_in`: Seconds until token should be refreshed (typically 1500 = 25 minutes) +- `headers`: (optional) HTTP headers to use for all Copilot API requests (see below) +### HTTP Headers Configuration + +The `headers` section allows you to customize the HTTP headers sent to the Copilot API. All fields are optional; defaults are shown below: + +| Field | Default Value | Description | +|--------------------------|-------------------------------|--------------------------------------------------| +| `user_agent` | GitHubCopilotChat/0.29.1 | User-Agent header for all requests | +| `editor_version` | vscode/1.102.3 | Editor-Version header | +| `editor_plugin_version` | copilot-chat/0.29.1 | Editor-Plugin-Version header | +| `copilot_integration_id` | vscode-chat | Copilot-Integration-Id header | +| `openai_intent` | conversation-edits | Openai-Intent header | +| `x_initiator` | user | X-Initiator header | + +You can override any of these by editing your `config.json`. ### Timeout Configuration @@ -294,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: -| 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 | +### 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 | -**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 +### 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 | + +**Note:** Use the `/v1/models` endpoint to see all available models and their `api_type` field to determine which endpoint to use. ## Security @@ -332,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 @@ -347,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 @@ -385,21 +542,28 @@ response = llm("Write a hello world in Python") print(response) ``` -## Development +### Using with Codex CLI -### Project Structure -``` -github-copilot-svcs/ -├── main.go # Main application and CLI -├── auth.go # GitHub Copilot authentication -├── proxy.go # Reverse proxy implementation -├── server.go # Server utilities and graceful shutdown -├── transform.go # Request/response transformation -├── cli.go # CLI command handling -├── go.mod # Go module definition -└── README.md # This documentation +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 ```bash git clone @@ -407,16 +571,31 @@ cd github-copilot-svcs make build # or manually: go mod tidy -go build -o github-copilot-svcs +go build -o github-copilot-svcs ./cmd/github-copilot-svcs ``` ### Running Tests ```bash -make test +make test # Run unit tests +make test-all # Run all tests (unit + integration) +make test-coverage # Run tests with coverage report # or manually: -go test ./... +go test ./test/... ``` +### Test Coverage +The project includes comprehensive test coverage: +- **Unit Tests**: Testing individual components (auth, config, logger) +- **Integration Tests**: Testing API endpoints and server functionality +- **Coverage Reports**: HTML and terminal coverage reports available + +Generate coverage reports: +```bash +make test-coverage # Generates coverage.html and shows terminal summary +``` + +Current test coverage: **~45%** across all packages, with excellent coverage in core components like logging (95%+) and configuration (58%+). + ## License Apache License 2.0 - see LICENSE file for details. @@ -425,15 +604,45 @@ This is free software: you are free to change and redistribute it under the term ## Contributing +We welcome contributions! Please follow these guidelines: + 1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly -5. Submit a pull request +2. Create a feature branch (use descriptive names) +3. Make your changes (follow Go code style and best practices) +4. Add or update tests as needed +5. Run all tests and ensure coverage is not reduced +6. Document your changes in the README if relevant +7. Submit a pull request with a clear description + +## Security + +- Tokens and secrets are stored securely in the user's home directory with restricted permissions (0700) +- No sensitive data is logged +- All communication with GitHub Copilot uses HTTPS +- Automatic token refresh prevents long-lived token exposure +- Do not commit secrets or sensitive config files; check your `.gitignore` +- For security issues, please contact the maintainers directly + +## FAQ / Common Issues + +**Q: Authentication fails or times out** +A: Run `./github-copilot-svcs auth` again and check your network connection. Ensure your GitHub account has Copilot access. + +**Q: Service won't start or port is in use** +A: Edit your config file to use a different port, or stop the conflicting service. + +**Q: Token expires too quickly** +A: Check your system clock and ensure the refresh interval in config is set correctly. + +**Q: How do I update configuration?** +A: Edit `~/.local/share/github-copilot-svcs/config.json` or use environment variables if supported. + +**Q: How do I report a bug or request a feature?** +A: Open an issue on GitHub with details about your environment and the problem. ## Support For issues and questions: -1. Check the troubleshooting section +1. Check the troubleshooting and FAQ sections 2. Review the logs for error messages 3. Open an issue with detailed information about your setup and the problem diff --git a/auth.go b/auth.go deleted file mode 100644 index 86bfffc..0000000 --- a/auth.go +++ /dev/null @@ -1,212 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "strings" - "time" -) - -const ( - copilotDeviceCodeURL = "https://github.com/login/device/code" - copilotTokenURL = "https://github.com/login/oauth/access_token" - copilotAPIKeyURL = "https://api.github.com/copilot_internal/v2/token" - copilotClientID = "Iv1.b507a08c87ecfe98" - copilotScope = "read:user" - userAgent = "GitHubCopilotChat/0.26.7" - - // Retry configuration - maxRefreshRetries = 3 - baseRetryDelay = 2 // seconds -) - -type deviceCodeResponse struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` -} - -type tokenResponse struct { - AccessToken string `json:"access_token"` - Error string `json:"error,omitempty"` - ErrorDesc string `json:"error_description,omitempty"` -} - -type copilotTokenResponse struct { - Token string `json:"token"` - ExpiresAt int64 `json:"expires_at"` - RefreshIn int64 `json:"refresh_in"` - Endpoints struct { - API string `json:"api"` - } `json:"endpoints"` -} - -func authenticate(cfg *Config) error { - now := time.Now().Unix() - if cfg.CopilotToken != "" && cfg.ExpiresAt > now+60 { - log.Printf("Token still valid: expires in %d seconds", cfg.ExpiresAt-now) - return nil // Already authenticated - } - - if cfg.CopilotToken != "" { - log.Printf("Token expired or expiring soon: expires in %d seconds, triggering re-auth", cfg.ExpiresAt-now) - } else { - log.Printf("No token found, starting authentication flow") - } - - // Step 1: Get device code - req, err := http.NewRequest("POST", copilotDeviceCodeURL, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - - body := fmt.Sprintf(`{"client_id":"%s","scope":"%s"}`, copilotClientID, copilotScope) - req.Body = io.NopCloser(strings.NewReader(body)) - - resp, err := sharedHTTPClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - var dc deviceCodeResponse - if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { - return err - } - - fmt.Printf("\nTo authenticate, visit: %s\nEnter code: %s\n", dc.VerificationURI, dc.UserCode) - - // Step 2: Poll for GitHub token - githubToken, err := pollForGitHubToken(dc.DeviceCode, dc.Interval) - if err != nil { - return err - } - cfg.GitHubToken = githubToken - - // Step 3: Exchange GitHub token for Copilot token - copilotToken, expiresAt, refreshIn, err := getCopilotToken(githubToken) - if err != nil { - return err - } - - cfg.CopilotToken = copilotToken - cfg.ExpiresAt = expiresAt - cfg.RefreshIn = refreshIn - - if err := saveConfig(cfg); err != nil { - return err - } - - fmt.Println("Authentication successful!") - return nil -} - -func pollForGitHubToken(deviceCode string, interval int) (string, error) { - for i := 0; i < 120; i++ { // Poll for 2 minutes max - time.Sleep(time.Duration(interval) * time.Second) - - req, err := http.NewRequest("POST", copilotTokenURL, nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - - body := fmt.Sprintf(`{"client_id":"%s","device_code":"%s","grant_type":"urn:ietf:params:oauth:grant-type:device_code"}`, - copilotClientID, deviceCode) - req.Body = io.NopCloser(strings.NewReader(body)) - - resp, err := sharedHTTPClient.Do(req) - if err != nil { - continue - } - - var tr tokenResponse - json.NewDecoder(resp.Body).Decode(&tr) - resp.Body.Close() - - if tr.Error != "" { - if tr.Error == "authorization_pending" { - continue - } - return "", fmt.Errorf("authorization error: %s - %s", tr.Error, tr.ErrorDesc) - } - - if tr.AccessToken != "" { - return tr.AccessToken, nil - } - } - - return "", fmt.Errorf("authentication timed out") -} - -func getCopilotToken(githubToken string) (string, int64, int64, error) { - req, err := http.NewRequest("GET", copilotAPIKeyURL, nil) - if err != nil { - return "", 0, 0, err - } - req.Header.Set("Authorization", "token "+githubToken) - req.Header.Set("User-Agent", userAgent) - - resp, err := sharedHTTPClient.Do(req) - if err != nil { - return "", 0, 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", 0, 0, fmt.Errorf("failed to get Copilot token: %d", resp.StatusCode) - } - - var ctr copilotTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&ctr); err != nil { - return "", 0, 0, err - } - - return ctr.Token, ctr.ExpiresAt, ctr.RefreshIn, nil -} - -func refreshToken(cfg *Config) error { - if cfg.GitHubToken == "" { - log.Printf("Cannot refresh token: no GitHub token available") - return errors.New("no GitHub token available for refresh") - } - - // Retry with exponential backoff - for attempt := 1; attempt <= maxRefreshRetries; attempt++ { - log.Printf("Attempting to refresh Copilot token (attempt %d/%d)", attempt, maxRefreshRetries) - - copilotToken, expiresAt, refreshIn, err := getCopilotToken(cfg.GitHubToken) - if err != nil { - if attempt == maxRefreshRetries { - log.Printf("Token refresh failed after %d attempts: %v", maxRefreshRetries, err) - return err - } - - // Wait before retry with exponential backoff - waitTime := time.Duration(baseRetryDelay*attempt*attempt) * time.Second - log.Printf("Token refresh failed (attempt %d), retrying in %v: %v", attempt, waitTime, err) - time.Sleep(waitTime) - continue - } - - log.Printf("Token refresh successful: new token expires in %d seconds", expiresAt-time.Now().Unix()) - cfg.CopilotToken = copilotToken - cfg.ExpiresAt = expiresAt - cfg.RefreshIn = refreshIn - - return saveConfig(cfg) - } - - return errors.New("maximum retry attempts exceeded") -} diff --git a/cli.go b/cli.go deleted file mode 100644 index b7c18bb..0000000 --- a/cli.go +++ /dev/null @@ -1,233 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "net/http" - _ "net/http/pprof" - "os" - "time" -) - -func printUsage() { - fmt.Printf("GitHub Copilot SVCS Proxy\n\n") - fmt.Printf("Usage: %s [command] [options]\n\n", os.Args[0]) - fmt.Printf("Commands:\n") - fmt.Printf(" start Start the proxy server (default)\n") - fmt.Printf(" auth Authenticate with GitHub Copilot\n") - fmt.Printf(" status Show authentication status\n") - fmt.Printf(" config Show current configuration\n") - fmt.Printf(" help Show this help message\n\n") - fmt.Printf("Options:\n") - flag.PrintDefaults() -} - -func handleAuth() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - // Initialize timeout configurations before any HTTP operations - initializeTimeouts(cfg) - - fmt.Println("Starting GitHub Copilot authentication...") - if err := authenticate(cfg); err != nil { - return fmt.Errorf("authentication failed: %v", err) - } - - fmt.Println("Authentication successful!") - return nil -} - -func handleStatus() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - fmt.Printf("Configuration file: %s\n", func() string { - path, _ := getConfigPath() - return path - }()) - fmt.Printf("Port: %d\n", cfg.Port) - - now := getCurrentTime() - if cfg.CopilotToken != "" { - fmt.Printf("Authentication: ✓ Authenticated\n") - - timeUntilExpiry := cfg.ExpiresAt - now - if timeUntilExpiry > 0 { - minutes := timeUntilExpiry / 60 - seconds := timeUntilExpiry % 60 - fmt.Printf("Token expires: in %dm %ds (%d seconds)\n", minutes, seconds, timeUntilExpiry) - - // Show refresh timing - if cfg.RefreshIn > 0 { - refreshThreshold := cfg.RefreshIn / 5 // 20% - if refreshThreshold < 300 { - refreshThreshold = 300 // minimum 5 minutes - } - if timeUntilExpiry <= refreshThreshold { - fmt.Printf("Status: ⚠️ Token will be refreshed soon (threshold: %d seconds)\n", refreshThreshold) - } else { - fmt.Printf("Status: ✅ Token is healthy\n") - } - } - } else { - fmt.Printf("Token expires: ⚠️ EXPIRED (%d seconds ago)\n", -timeUntilExpiry) - fmt.Printf("Status: ❌ Token needs refresh\n") - } - - fmt.Printf("Has GitHub token: %t\n", cfg.GitHubToken != "") - if cfg.RefreshIn > 0 { - fmt.Printf("Refresh interval: %d seconds\n", cfg.RefreshIn) - } - } else { - fmt.Printf("Authentication: ✗ Not authenticated\n") - fmt.Printf("Run '%s auth' to authenticate\n", os.Args[0]) - } - - return nil -} - -func handleConfig() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - path, _ := getConfigPath() - fmt.Printf("Configuration file: %s\n", path) - fmt.Printf("Port: %d\n", cfg.Port) - fmt.Printf("Has GitHub token: %t\n", cfg.GitHubToken != "") - fmt.Printf("Has Copilot token: %t\n", cfg.CopilotToken != "") - if cfg.ExpiresAt > 0 { - fmt.Printf("Token expires at: %d\n", cfg.ExpiresAt) - } - - return nil -} - -func getCurrentTime() int64 { - return time.Now().Unix() -} - -func handleRun() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - // Initialize timeout configurations before any HTTP operations - initializeTimeouts(cfg) - - // Ensure we're authenticated - if err := ensureValidToken(cfg); err != nil { - return fmt.Errorf("authentication failed: %v", err) - } - - setupLogging() - - mux := http.NewServeMux() - mux.HandleFunc("/v1/models", modelsHandler(cfg)) - mux.HandleFunc("/v1/chat/completions", proxyHandler(cfg)) - mux.HandleFunc("/health", healthHandler) - // Add pprof endpoints for profiling - mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP) - mux.HandleFunc("/debug/pprof/cmdline", http.DefaultServeMux.ServeHTTP) - mux.HandleFunc("/debug/pprof/profile", http.DefaultServeMux.ServeHTTP) - mux.HandleFunc("/debug/pprof/symbol", http.DefaultServeMux.ServeHTTP) - mux.HandleFunc("/debug/pprof/trace", http.DefaultServeMux.ServeHTTP) - - port := cfg.Port - if port == 0 { - port = 8081 - } - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: mux, - ReadTimeout: time.Duration(cfg.Timeouts.ServerRead) * time.Second, - WriteTimeout: time.Duration(cfg.Timeouts.ServerWrite) * time.Second, - IdleTimeout: time.Duration(cfg.Timeouts.ServerIdle) * time.Second, - } - - setupGracefulShutdown(server) - - fmt.Printf("Starting GitHub Copilot proxy server on port %d...\n", port) - 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(" - Health: http://localhost:%d/health\n", port) - - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("server failed: %v", err) - } - - return nil -} - -func handleModels() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - // Initialize timeout configurations before any HTTP operations - initializeTimeouts(cfg) - - // Ensure we're authenticated - if err := ensureValidToken(cfg); err != nil { - return fmt.Errorf("authentication failed: %v", err) - } - - // Fetch models - models, err := fetchModelsFromModelsDev() - if err != nil { - fmt.Printf("Failed to fetch models from models.dev: %v\n", err) - fmt.Println("Using default models:") - defaultModels := getDefaultModels() - for _, model := range defaultModels { - fmt.Printf(" - %s (%s)\n", model.ID, model.OwnedBy) - } - return nil - } - - fmt.Printf("Available models (%d total):\n", len(models.Data)) - for _, model := range models.Data { - fmt.Printf(" - %s (%s)\n", model.ID, model.OwnedBy) - } - - return nil -} - -func handleRefresh() error { - cfg, err := loadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %v", err) - } - - // Initialize timeout configurations before any HTTP operations - initializeTimeouts(cfg) - - if cfg.CopilotToken == "" { - return fmt.Errorf("no token to refresh - run 'auth' command first") - } - - fmt.Println("Forcing token refresh...") - if err := refreshToken(cfg); err != nil { - return fmt.Errorf("token refresh failed: %v", err) - } - - fmt.Printf("✅ Token refresh successful!\n") - - // Show new expiration time - now := getCurrentTime() - timeUntilExpiry := cfg.ExpiresAt - now - minutes := timeUntilExpiry / 60 - seconds := timeUntilExpiry % 60 - fmt.Printf("New token expires in: %dm %ds\n", minutes, seconds) - - return nil -} diff --git a/cmd/github-copilot-svcs/main.go b/cmd/github-copilot-svcs/main.go new file mode 100644 index 0000000..7226d6a --- /dev/null +++ b/cmd/github-copilot-svcs/main.go @@ -0,0 +1,27 @@ +// Package main is the entry point for github-copilot-svcs. +package main + +import ( + "os" + + "github.com/privapps/github-copilot-svcs/internal" +) + +// version will be set by the build process +var version = "dev" + +func main() { + // Initialize logger early + internal.Init() + + const minArgsRequired = 2 + if len(os.Args) < minArgsRequired { + internal.PrintUsage() + return + } + + if err := internal.RunCommand(os.Args[1], os.Args[2:], version); err != nil { + internal.Error("Command failed", err) + os.Exit(1) + } +} diff --git a/config.example.json b/config.example.json index d62303d..504c95b 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,14 @@ { "port": 8081, + "allowed_models": null, + "headers": { + "user_agent": "GitHubCopilotChat/0.29.1", + "editor_version": "vscode/1.102.3", + "editor_plugin_version": "copilot-chat/0.29.1", + "copilot_integration_id": "vscode-chat", + "openai_intent": "conversation-edits", + "x_initiator": "user" + }, "timeouts": { "http_client": 300, "server_read": 30, diff --git a/config.go b/config.go deleted file mode 100644 index 9745148..0000000 --- a/config.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "encoding/json" - "os" -) - -func loadConfig() (*Config, error) { - path, err := getConfigPath() - if err != nil { - return nil, err - } - - file, err := os.Open(path) - if err != nil { - // Return default config if file doesn't exist - cfg := &Config{Port: 8081} - setDefaultTimeouts(cfg) - return cfg, nil - } - defer file.Close() - - var cfg Config - if err := json.NewDecoder(file).Decode(&cfg); err != nil { - return nil, err - } - - // Set default port if not specified - if cfg.Port == 0 { - cfg.Port = 8081 - } - - // Set default timeouts if not specified - setDefaultTimeouts(&cfg) - - return &cfg, nil -} - -// setDefaultTimeouts sets default timeout values if they are zero -func setDefaultTimeouts(cfg *Config) { - if cfg.Timeouts.HTTPClient == 0 { - cfg.Timeouts.HTTPClient = 300 - } - if cfg.Timeouts.ServerRead == 0 { - cfg.Timeouts.ServerRead = 30 - } - if cfg.Timeouts.ServerWrite == 0 { - cfg.Timeouts.ServerWrite = 300 - } - if cfg.Timeouts.ServerIdle == 0 { - cfg.Timeouts.ServerIdle = 120 - } - if cfg.Timeouts.ProxyContext == 0 { - cfg.Timeouts.ProxyContext = 300 - } - if cfg.Timeouts.CircuitBreaker == 0 { - cfg.Timeouts.CircuitBreaker = 30 - } - if cfg.Timeouts.KeepAlive == 0 { - cfg.Timeouts.KeepAlive = 30 - } - if cfg.Timeouts.TLSHandshake == 0 { - cfg.Timeouts.TLSHandshake = 10 - } - if cfg.Timeouts.DialTimeout == 0 { - cfg.Timeouts.DialTimeout = 10 - } - if cfg.Timeouts.IdleConnTimeout == 0 { - cfg.Timeouts.IdleConnTimeout = 90 - } -} - -func saveConfig(cfg *Config) error { - path, err := getConfigPath() - if err != nil { - return err - } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - return json.NewEncoder(f).Encode(cfg) -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d5504c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + github-copilot-svcs: + build: . + ports: + - "8081:8081" + environment: + - COPILOT_PORT=8081 + - LOG_LEVEL=info + volumes: + # Mount config directory for persistent authentication + - ./config:/home/appuser/.local/share/github-copilot-svcs + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - copilot-network + +networks: + copilot-network: + driver: bridge diff --git a/go.mod b/go.mod index c119e20..6ee0444 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ -module github.com/yourname/github-copilot-svcs +module github.com/privapps/github-copilot-svcs -go 1.21 +go 1.23.0 + +toolchain go1.23.5 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/auth.go b/internal/auth.go new file mode 100644 index 0000000..28e37a8 --- /dev/null +++ b/internal/auth.go @@ -0,0 +1,324 @@ +// Package internal provides core authentication, proxy, and service logic for github-copilot-svcs. +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + copilotDeviceCodeURL = "https://github.com/login/device/code" + copilotTokenURL = "https://github.com/login/oauth/access_token" + copilotAPIKeyURL = "https://api.github.com/copilot_internal/v2/token" + copilotClientID = "Iv1.b507a08c87ecfe98" + copilotScope = "read:user" + + // Retry configuration + maxRefreshRetries = 3 + baseRetryDelay = 2 // seconds +) + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error,omitempty"` + ErrorDesc string `json:"error_description,omitempty"` +} + +type copilotTokenResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` + RefreshIn int64 `json:"refresh_in"` + Endpoints struct { + API string `json:"api"` + } `json:"endpoints"` +} + +// AuthService provides authentication operations for GitHub Copilot. +type AuthService struct { + httpClient *http.Client + + // For testability: override config save path + configPath string + + // For testability: optional custom token refresh function + refreshFunc func(cfg *Config) error +} + +// NewAuthService creates a new auth service +func NewAuthService(httpClient *http.Client, opts ...func(*AuthService)) *AuthService { + svc := &AuthService{ + httpClient: httpClient, + } + for _, opt := range opts { + opt(svc) + } + return svc +} + +// WithConfigPath sets the config path for AuthService. +// WithConfigPath is used for tests. +func WithConfigPath(path string) func(*AuthService) { + return func(s *AuthService) { + s.configPath = path + } +} + +// WithRefreshFunc sets a custom refresh function for AuthService. +func WithRefreshFunc(f func(cfg *Config) error) func(*AuthService) { + return func(s *AuthService) { + s.refreshFunc = f + } +} + +// Authenticate performs the full GitHub Copilot authentication flow +func (s *AuthService) Authenticate(cfg *Config) error { + now := time.Now().Unix() + if cfg.CopilotToken != "" && cfg.ExpiresAt > now+60 { + Info("Token still valid", "expires_in", cfg.ExpiresAt-now) + return nil // Already authenticated + } + + if cfg.CopilotToken != "" { + Info("Token expired or expiring soon, triggering re-auth", "expires_in", cfg.ExpiresAt-now) + } else { + Info("No token found, starting authentication flow") + } + + // Step 1: Get device code + dc, err := s.getDeviceCode(cfg) + if err != nil { + return fmt.Errorf("failed to get device code: %w", err) + } + + fmt.Printf("\nTo authenticate, visit: %s\nEnter code: %s\n", dc.VerificationURI, dc.UserCode) + + // Step 2: Poll for GitHub token + githubToken, err := s.pollForGitHubToken(cfg, dc.DeviceCode, dc.Interval) + if err != nil { + return fmt.Errorf("failed to get GitHub token: %w", err) + } + cfg.GitHubToken = githubToken + + // Step 3: Exchange GitHub token for Copilot token + copilotToken, expiresAt, refreshIn, err := s.getCopilotToken(cfg, githubToken) + if err != nil { + return fmt.Errorf("failed to get Copilot token: %w", err) + } + + cfg.CopilotToken = copilotToken + cfg.ExpiresAt = expiresAt + cfg.RefreshIn = refreshIn + + var saveErr error + if s.configPath != "" { + saveErr = cfg.SaveConfig(s.configPath) + } else { + saveErr = cfg.SaveConfig() + } + if saveErr != nil { + return fmt.Errorf("failed to save config: %w", saveErr) + } + + fmt.Println("Authentication successful!") + return nil +} + +// RefreshToken refreshes the Copilot token using the stored GitHub token +func (s *AuthService) RefreshToken(cfg *Config) error { + return s.RefreshTokenWithContext(context.Background(), cfg) +} + +// RefreshTokenWithContext refreshes the Copilot token using the provided context and config. +func (s *AuthService) RefreshTokenWithContext(ctx context.Context, cfg *Config) error { + if s.refreshFunc != nil { + // Use injected refresh function for tests + err := s.refreshFunc(cfg) + if err != nil { + return err + } + // Save config to injected path if set + if s.configPath != "" { + return cfg.SaveConfig(s.configPath) + } + return cfg.SaveConfig() + } + + if cfg.GitHubToken == "" { + Warn("Cannot refresh token: no GitHub token available") + return NewAuthError("no GitHub token available for refresh", nil) + } + + // Retry with exponential backoff + for attempt := 1; attempt <= maxRefreshRetries; attempt++ { + Info("Attempting to refresh Copilot token", "attempt", attempt, "max_attempts", maxRefreshRetries) + + copilotToken, expiresAt, refreshIn, err := s.getCopilotToken(cfg, cfg.GitHubToken) + if err != nil { + if attempt == maxRefreshRetries { + Error("Token refresh failed after max attempts", "attempts", maxRefreshRetries, "error", err) + return err + } + + // Wait before retry with exponential backoff + waitTime := time.Duration(baseRetryDelay*attempt*attempt) * time.Second + Warn("Token refresh failed, retrying", "attempt", attempt, "wait_time", waitTime, "error", err) + + // Use context-aware sleep + select { + case <-time.After(waitTime): + continue + case <-ctx.Done(): + return ctx.Err() + } + } + + Info("Token refresh successful", "expires_in", expiresAt-time.Now().Unix()) + cfg.CopilotToken = copilotToken + cfg.ExpiresAt = expiresAt + cfg.RefreshIn = refreshIn + + return cfg.SaveConfig() + } + + return NewAuthError("maximum retry attempts exceeded", nil) +} + +// EnsureValidToken ensures we have a valid token, refreshing if necessary +func (s *AuthService) EnsureValidToken(cfg *Config) error { + now := time.Now().Unix() + if cfg.CopilotToken == "" { + return NewAuthError("no token available - authentication required", nil) + } + + // Check if token needs refresh (within 5 minutes of expiry or already expired) + if cfg.ExpiresAt <= now+300 { + return s.RefreshToken(cfg) + } + + return nil +} + +func (s *AuthService) getDeviceCode(cfg *Config) (*deviceCodeResponse, error) { + body := fmt.Sprintf(`{"client_id":%q,"scope":%q}`, copilotClientID, copilotScope) + req, err := http.NewRequest("POST", copilotDeviceCodeURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", cfg.Headers.UserAgent) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() + + var dc deviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { + return nil, err + } + + return &dc, nil +} + +func (s *AuthService) pollForGitHubToken(cfg *Config, deviceCode string, interval int) (string, error) { + return s.pollForGitHubTokenWithContext(context.Background(), cfg, deviceCode, interval) +} + +func (s *AuthService) pollForGitHubTokenWithContext(ctx context.Context, cfg *Config, deviceCode string, interval int) (string, error) { + for i := 0; i < 120; i++ { // Poll for 2 minutes max + // Use context-aware sleep + select { + case <-time.After(time.Duration(interval) * time.Second): + // Continue with polling + case <-ctx.Done(): + return "", ctx.Err() + } + + body := fmt.Sprintf(`{"client_id":%q,"device_code":%q,"grant_type":"urn:ietf:params:oauth:grant-type:device_code"}`, + copilotClientID, deviceCode) + req, err := http.NewRequest("POST", copilotTokenURL, strings.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", cfg.Headers.UserAgent) + + resp, err := s.httpClient.Do(req) + if err != nil { + continue + } + + var tr tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + continue + } + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + + if tr.Error != "" { + if tr.Error == "authorization_pending" { + continue + } + return "", NewAuthError(fmt.Sprintf("authorization failed: %s - %s", tr.Error, tr.ErrorDesc), nil) + } + + if tr.AccessToken != "" { + return tr.AccessToken, nil + } + } + + return "", NewAuthError("authentication timed out", nil) +} + +func (s *AuthService) getCopilotToken(cfg *Config, githubToken string) (token string, expiresAt, refreshIn int64, err error) { + req, err := http.NewRequest("GET", copilotAPIKeyURL, http.NoBody) + if err != nil { + return "", 0, 0, err + } + req.Header.Set("Authorization", "token "+githubToken) + req.Header.Set("User-Agent", cfg.Headers.UserAgent) + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", 0, 0, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", 0, 0, NewNetworkError("getCopilotToken", copilotAPIKeyURL, fmt.Sprintf("HTTP %d response", resp.StatusCode), nil) + } + + var ctr copilotTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&ctr); err != nil { + return "", 0, 0, err + } + + return ctr.Token, ctr.ExpiresAt, ctr.RefreshIn, nil +} diff --git a/internal/auth_test.go b/internal/auth_test.go new file mode 100644 index 0000000..158a5d4 --- /dev/null +++ b/internal/auth_test.go @@ -0,0 +1,349 @@ +package internal_test + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + "time" + + "github.com/privapps/github-copilot-svcs/internal" +) + +// Test constants +const ( + testUserAgent = "test-agent/1.0" +) + +// Helper function to create a basic test config +func createAuthTestConfig() *internal.Config { + return &internal.Config{ + Headers: struct { + UserAgent string `json:"user_agent"` + EditorVersion string `json:"editor_version"` + EditorPluginVersion string `json:"editor_plugin_version"` + CopilotIntegrationID string `json:"copilot_integration_id"` + OpenaiIntent string `json:"openai_intent"` + XInitiator string `json:"x_initiator"` + }{ + UserAgent: testUserAgent, + }, + } +} + +func TestAuthService_EnsureValidToken(t *testing.T) { + tests := []struct { + name string + setupConfig func() *internal.Config + expectedError bool + }{ + { + name: "no token", + setupConfig: createAuthTestConfig, + expectedError: true, + }, + { + name: "valid token - not expiring soon", + setupConfig: func() *internal.Config { + cfg := createAuthTestConfig() + cfg.CopilotToken = "valid_token" + cfg.ExpiresAt = time.Now().Add(time.Hour).Unix() // Expires in 1 hour + return cfg + }, + expectedError: false, + }, + { + name: "token expiring soon - but no github token to refresh", + setupConfig: func() *internal.Config { + cfg := createAuthTestConfig() + cfg.CopilotToken = "expiring_token" + cfg.ExpiresAt = time.Now().Add(2 * time.Minute).Unix() // Expires in 2 minutes + // No GitHubToken, so refresh should fail + return cfg + }, + expectedError: true, + }, + { + name: "expired token - but no github token to refresh", + setupConfig: func() *internal.Config { + cfg := createAuthTestConfig() + cfg.CopilotToken = "expired_token" + cfg.ExpiresAt = time.Now().Unix() - 100 // Expired 100 seconds ago + // No GitHubToken, so refresh should fail + return cfg + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setupConfig() + + // Use a basic client for non-HTTP tests + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + err := authService.EnsureValidToken(cfg) + + if tt.expectedError { + if err == nil { + t.Error("Expected error but got none") + } else { + t.Logf("Got expected error: %v", err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestAuthService_RefreshToken_ValidationLogic(t *testing.T) { + tests := []struct { + name string + setupConfig func() *internal.Config + expectedError bool + errorContains string + }{ + { + name: "no github token", + setupConfig: func() *internal.Config { + cfg := createAuthTestConfig() + cfg.CopilotToken = "old_token" + // No GitHubToken set + return cfg + }, + expectedError: true, + errorContains: "no GitHub token available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setupConfig() + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + + err := authService.RefreshToken(cfg) + + if tt.expectedError { + if err == nil { + t.Error("Expected error but got none") + } else { + t.Logf("Got expected error: %v", err) + if tt.errorContains != "" && err.Error() != "" { + // We expect the error to contain certain text + t.Logf("Error contains expected text: %q", tt.errorContains) + } + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestAuthService_RefreshTokenWithContext_CancellationLogic(t *testing.T) { + // Test that validates context cancellation is properly handled + // This test focuses on the context handling logic without HTTP complexity + tests := []struct { + name string + setupConfig func() *internal.Config + setupCtx func() context.Context + expectError bool + }{ + { + name: "context already canceled", + setupConfig: func() *internal.Config { + cfg := createAuthTestConfig() + cfg.GitHubToken = "test_token" // Has github token + return cfg + }, + setupCtx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + return ctx + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setupConfig() + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + ctx := tt.setupCtx() + + err := authService.RefreshTokenWithContext(ctx, cfg) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } else { + t.Logf("Got expected error: %v", err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +// Test NewAuthService constructor +func TestNewAuthService(t *testing.T) { + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + if authService == nil { + t.Error("NewAuthService returned nil") + } +} + +// Test token expiry calculation logic +func TestTokenExpiryLogic(t *testing.T) { + tests := []struct { + name string + expiresAt int64 + currentTime int64 + shouldBeValid bool + description string + }{ + { + name: "token valid for 1 hour", + expiresAt: time.Now().Add(time.Hour).Unix(), + shouldBeValid: true, + description: "Token expires in 1 hour, should be valid", + }, + { + name: "token expiring in 2 minutes", + expiresAt: time.Now().Add(2 * time.Minute).Unix(), + shouldBeValid: false, + description: "Token expires in 2 minutes, should trigger refresh", + }, + { + name: "token expired 1 hour ago", + expiresAt: time.Now().Add(-time.Hour).Unix(), + shouldBeValid: false, + description: "Token expired 1 hour ago, should trigger refresh", + }, + { + name: "token expiring in exactly 5 minutes", + expiresAt: time.Now().Add(5 * time.Minute).Unix(), + shouldBeValid: false, + description: "Token expires in exactly 5 minutes, should trigger refresh", + }, + { + name: "token expiring in 6 minutes", + expiresAt: time.Now().Add(6 * time.Minute).Unix(), + shouldBeValid: true, + description: "Token expires in 6 minutes, should still be valid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := createAuthTestConfig() + cfg.CopilotToken = "test_token" + cfg.ExpiresAt = tt.expiresAt + + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + err := authService.EnsureValidToken(cfg) + + if tt.shouldBeValid { + if err != nil { + t.Errorf("Expected token to be valid, but got error: %v", err) + } + } else { + if err == nil { + t.Error("Expected token to need refresh, but no error was returned") + } + } + + t.Logf("%s: %v", tt.description, err) + }) + } +} + +// Benchmark tests for performance verification +func BenchmarkAuthService_EnsureValidToken_ValidToken(b *testing.B) { + cfg := createAuthTestConfig() + cfg.CopilotToken = "valid_token" + cfg.ExpiresAt = time.Now().Add(time.Hour).Unix() + + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = authService.EnsureValidToken(cfg) + } +} + +func BenchmarkAuthService_EnsureValidToken_ExpiredToken(b *testing.B) { + cfg := createAuthTestConfig() + cfg.CopilotToken = "expired_token" + cfg.ExpiresAt = time.Now().Add(-time.Hour).Unix() // Expired + + authService := internal.NewAuthService(&http.Client{Timeout: 1 * time.Second}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = authService.EnsureValidToken(cfg) // Will return error quickly + } +} + +// Test that RefreshToken saves to config file without hitting network +/* Obsolete TestRefreshTokenSavesConfig removed; use TestAuthService_RefreshToken_SavesConfig instead */ + +// Test that RefreshToken saves to the injected config path +func TestAuthService_RefreshToken_SavesConfig(t *testing.T) { + // Create temp config file + tmpfile, err := os.CreateTemp("", "copilot-config-*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + cfg := createAuthTestConfig() + cfg.GitHubToken = "dummy-github-token" + + // Dummy refresh func (no network) + refreshFunc := func(c *internal.Config) error { + c.CopilotToken = "dummy-copilot-token" + c.ExpiresAt = time.Now().Unix() + 3600 + c.RefreshIn = 1800 + return nil + } + + authSvc := internal.NewAuthService(&http.Client{}, + internal.WithConfigPath(tmpfile.Name()), + internal.WithRefreshFunc(refreshFunc), + ) + + if refreshErr := authSvc.RefreshToken(cfg); refreshErr != nil { + t.Fatalf("RefreshToken failed: %v", refreshErr) + } + + // Read back the config file + loaded := &internal.Config{} + f, openErr := os.Open(tmpfile.Name()) + if openErr != nil { + t.Fatalf("failed to open temp config file: %v", openErr) + } + defer f.Close() + if decodeErr := json.NewDecoder(f).Decode(loaded); decodeErr != nil { + t.Fatalf("failed to decode config: %v", decodeErr) + } + + if loaded.CopilotToken != "dummy-copilot-token" { + t.Errorf("CopilotToken not saved correctly, got: %v", loaded.CopilotToken) + } + if loaded.ExpiresAt == 0 { + t.Errorf("ExpiresAt not saved") + } + if loaded.RefreshIn == 0 { + t.Errorf("RefreshIn not saved") + } +} diff --git a/internal/cli.go b/internal/cli.go new file mode 100644 index 0000000..dc8a5e9 --- /dev/null +++ b/internal/cli.go @@ -0,0 +1,380 @@ +package internal + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "github.com/privapps/github-copilot-svcs/pkg/transform" + "os" + "time" +) + +// Command constants to avoid goconst errors +const ( + cmdAuth = "auth" + cmdRun = "run" + cmdStart = "start" + cmdModels = "models" + cmdConfig = "config" + cmdStatus = "status" + cmdRefresh = "refresh" + + // Constants to avoid magic numbers + defaultRefreshThreshold = 300 // 5 minutes minimum refresh threshold + secondsInMinute = 60 + refreshPercentThreshold = 5 // 20% = 1/5 +) + +// PrintUsage prints the command usage information +func PrintUsage() { + fmt.Printf(`GitHub Copilot SVCS Proxy + +A reverse proxy for GitHub Copilot providing OpenAI-compatible endpoints. + +Usage: + %s [command] [options] + +Commands: + start Start the proxy server (default) + auth Authenticate with GitHub Copilot using device flow + status Show detailed authentication and token status + config Display current configuration details + models List all available AI models + refresh Manually force token refresh + help Show this help message + version Show version information + +Examples: + %s auth # Authenticate with GitHub + %s run --port 8080 # Run server on port 8080 + %s status --json # Show status in JSON format + +Environment Variables: + COPILOT_PORT Server port (default: 8081) + GITHUB_TOKEN GitHub OAuth token + COPILOT_TOKEN GitHub Copilot API token + LOG_LEVEL Log level (debug, info, warn, error) + +Options: +`, os.Args[0], os.Args[0], os.Args[0], os.Args[0]) + flag.PrintDefaults() +} + +// RunCommand executes the specified command with arguments +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() + case cmdRun, cmdStart: + return handleRun() + case cmdModels: + return handleModels() + case cmdConfig: + return handleConfig() + case cmdStatus: + return handleStatusWithFormat(jsonOutput) + case cmdRefresh: + return handleRefresh() + case "version": + fmt.Printf("github-copilot-svcs version %s\n", version) + return nil + case "help", "--help", "-h": + PrintUsage() + return nil + default: + logger.Error("Unknown command", "command", command) + PrintUsage() + return fmt.Errorf("unknown command: %s", command) + } +} + +func handleAuth() error { + cfg, err := LoadConfig(true) + if err != nil { + return fmt.Errorf("failed to load config: %v", err) + } + + // Create HTTP client with timeouts + httpClient := CreateHTTPClient(cfg) + authService := NewAuthService(httpClient) + + fmt.Println("Starting GitHub Copilot authentication...") + if err := authService.Authenticate(cfg); err != nil { + return fmt.Errorf("authentication failed: %v", err) + } + + fmt.Println("Authentication successful!") + return nil +} + +func handleStatusWithFormat(jsonOutput bool) error { + cfg, err := LoadConfig() + if err != nil { + if errors.Is(err, ErrMissingTokens) { + fmt.Println("Not authenticated. Run 'auth' to authenticate.") + return nil + } + return fmt.Errorf("failed to load config: %v", err) + } + + if jsonOutput { + return printStatusJSON(cfg) + } + return printStatusText(cfg) +} + +func printStatusJSON(cfg *Config) error { + path, _ := GetConfigPath() + now := getCurrentTime() + + status := map[string]interface{}{ + "config_file": path, + "port": cfg.Port, + "authenticated": cfg.CopilotToken != "", + "has_github_token": cfg.GitHubToken != "", + "refresh_interval": cfg.RefreshIn, + } + + if cfg.CopilotToken != "" { + timeUntilExpiry := cfg.ExpiresAt - now + status["token_expires_at"] = cfg.ExpiresAt + status["token_expires_in_seconds"] = timeUntilExpiry + + if timeUntilExpiry > 0 { + refreshThreshold := cfg.RefreshIn / refreshPercentThreshold + if refreshThreshold < defaultRefreshThreshold { + refreshThreshold = defaultRefreshThreshold + } + + if timeUntilExpiry <= refreshThreshold { + status["status"] = "token_will_refresh_soon" + } else { + status["status"] = "healthy" + } + } else { + status["status"] = "token_expired" + } + } else { + status["status"] = "not_authenticated" + } + + if err := json.NewEncoder(os.Stdout).Encode(status); err != nil { + return fmt.Errorf("failed to encode status as JSON: %w", err) + } + return nil +} + +func printStatusText(cfg *Config) error { + path, _ := GetConfigPath() + fmt.Printf("Configuration file: %s\n", path) + fmt.Printf("Port: %d\n", cfg.Port) + + now := getCurrentTime() + if cfg.CopilotToken != "" { + fmt.Printf("Authentication: ✓ Authenticated\n") + + timeUntilExpiry := cfg.ExpiresAt - now + if timeUntilExpiry > 0 { + minutes := timeUntilExpiry / secondsInMinute + seconds := timeUntilExpiry % secondsInMinute + fmt.Printf("Token expires: in %dm %ds (%d seconds)\n", minutes, seconds, timeUntilExpiry) + + // Show refresh timing + if cfg.RefreshIn > 0 { + refreshThreshold := cfg.RefreshIn / refreshPercentThreshold // 20% + if refreshThreshold < defaultRefreshThreshold { + refreshThreshold = defaultRefreshThreshold // minimum 5 minutes + } + if timeUntilExpiry <= refreshThreshold { + fmt.Printf("Status: ⚠️ Token will be refreshed soon (threshold: %d seconds)\n", refreshThreshold) + } else { + fmt.Printf("Status: ✅ Token is healthy\n") + } + } + } else { + fmt.Printf("Token expires: ⚠️ EXPIRED (%d seconds ago)\n", -timeUntilExpiry) + fmt.Printf("Status: ❌ Token needs refresh\n") + } + + fmt.Printf("Has GitHub token: %t\n", cfg.GitHubToken != "") + if cfg.RefreshIn > 0 { + fmt.Printf("Refresh interval: %d seconds\n", cfg.RefreshIn) + } + } else { + fmt.Printf("Authentication: ✗ Not authenticated\n") + fmt.Printf("Run '%s auth' to authenticate\n", os.Args[0]) + } + + return nil +} + +func handleConfig() error { + cfg, err := LoadConfig() + if err != nil { + if errors.Is(err, ErrMissingTokens) { + fmt.Println("Not authenticated. Run 'auth' to authenticate.") + return nil + } + return fmt.Errorf("failed to load config: %v", err) + } + + path, _ := GetConfigPath() + fmt.Printf("Configuration file: %s\n", path) + fmt.Printf("Port: %d\n", cfg.Port) + fmt.Printf("Has GitHub token: %t\n", cfg.GitHubToken != "") + fmt.Printf("Has Copilot token: %t\n", cfg.CopilotToken != "") + if cfg.ExpiresAt > 0 { + fmt.Printf("Token expires at: %d\n", cfg.ExpiresAt) + } + + fmt.Printf("\nHTTP Headers:\n") + fmt.Printf(" User-Agent: %s\n", cfg.Headers.UserAgent) + fmt.Printf(" Editor-Version: %s\n", cfg.Headers.EditorVersion) + fmt.Printf(" Editor-Plugin-Version: %s\n", cfg.Headers.EditorPluginVersion) + fmt.Printf(" Copilot-Integration-Id: %s\n", cfg.Headers.CopilotIntegrationID) + fmt.Printf(" Openai-Intent: %s\n", cfg.Headers.OpenaiIntent) + fmt.Printf(" X-Initiator: %s\n", cfg.Headers.XInitiator) + + return nil +} + +func getCurrentTime() int64 { + return time.Now().Unix() +} + +func handleRun() error { + cfg, err := LoadConfig() + if err != nil { + if errors.Is(err, ErrMissingTokens) { + if authErr := handleAuth(); authErr != nil { + return fmt.Errorf("authentication failed: %v", authErr) + } + cfg, err = LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config after authentication: %v", err) + } + } else { + return fmt.Errorf("failed to load config: %v", err) + } + } + + // Create HTTP client and auth service + httpClient := CreateHTTPClient(cfg) + authService := NewAuthService(httpClient) + + // Ensure we're authenticated + if err := authService.EnsureValidToken(cfg); err != nil { + return fmt.Errorf("authentication failed: %v", err) + } + + // Create and start server + srv := NewServer(cfg, httpClient) + return srv.Start() +} + +func handleModels() error { + cfg, err := LoadConfig() + if err != nil { + if errors.Is(err, ErrMissingTokens) { + fmt.Println("Not authenticated. Run 'auth' to authenticate.") + return nil + } + return fmt.Errorf("failed to load config: %v", err) + } + + // Create HTTP client and auth service + httpClient := CreateHTTPClient(cfg) + authService := NewAuthService(httpClient) + + // Ensure we're authenticated + if authErr := authService.EnsureValidToken(cfg); authErr != nil { + return fmt.Errorf("authentication failed: %v", authErr) + } + + // Fetch models + modelList, err := FetchFromModelsDev(httpClient) + if err != nil { + fmt.Printf("Failed to fetch models from models.dev: %v\n", err) + fmt.Println("Using default models:") + defaultModels := GetDefault() + for _, model := range defaultModels { + fmt.Printf(" - %s (%s)\n", model.ID, model.OwnedBy) + } + return nil + } + + 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 errors.Is(err, ErrMissingTokens) { + fmt.Println("Not authenticated. Run 'auth' to authenticate.") + return nil + } + return fmt.Errorf("failed to load config: %v", err) + } + + if cfg.CopilotToken == "" { + return fmt.Errorf("no token to refresh - run 'auth' command first") + } + + // Create HTTP client and auth service + httpClient := CreateHTTPClient(cfg) + authService := NewAuthService(httpClient) + + fmt.Println("Forcing token refresh...") + if err := authService.RefreshToken(cfg); err != nil { + return fmt.Errorf("token refresh failed: %v", err) + } + + fmt.Printf("✅ Token refresh successful!\n") + + // Show new expiration time + now := getCurrentTime() + timeUntilExpiry := cfg.ExpiresAt - now + minutes := timeUntilExpiry / secondsInMinute + seconds := timeUntilExpiry % secondsInMinute + fmt.Printf("New token expires in: %dm %ds\n", minutes, seconds) + + return nil +} diff --git a/internal/cli_test.go b/internal/cli_test.go new file mode 100644 index 0000000..b3d3c9a --- /dev/null +++ b/internal/cli_test.go @@ -0,0 +1,28 @@ +package internal + +import ( + "bytes" + "os" + "testing" +) + +func captureStdout(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + f() + w.Close() + os.Stdout = old + var buf bytes.Buffer + buf.ReadFrom(r) + return buf.String() +} + +func TestPrintUsage(t *testing.T) { + output := captureStdout(func() { + PrintUsage() + }) + if len(output) == 0 { + t.Error("PrintUsage did not print anything") + } +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..5e338d6 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,465 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" +) + +// Constants for configuration +const ( + configDirName = ".local/share/github-copilot-svcs" + configFileName = "config.json" + defaultServerPort = 8081 + dirPerm = 0o700 + + // Default header values + defaultUserAgent = "GitHubCopilotChat/0.29.1" + defaultEditorVersion = "vscode/1.102.3" + defaultEditorPluginVersion = "copilot-chat/0.29.1" + defaultCopilotIntegrationID = "vscode-chat" + defaultOpenaiIntent = "conversation-edits" + defaultXInitiator = "user" + + // Timeout defaults + defaultHTTPClientTimeout = 300 + defaultServerReadTimeout = 30 + defaultServerWriteTimeout = 300 + defaultServerIdleTimeout = 120 + defaultProxyContextTimeout = 300 + defaultCircuitBreakerTimeout = 30 + defaultKeepAliveTimeout = 30 + defaultTLSHandshakeTimeout = 10 + defaultDialTimeout = 10 + defaultIdleConnTimeout = 90 + + // Port validation + minPortNumber = 1 + maxPortNumber = 65535 + + // Timeout validation ranges + minTimeout = 1 + maxShortTimeout = 300 + maxLongTimeout = 3600 +) + +// 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"` + AllowedModels []string `json:"allowed_models"` + + // HTTP Headers configuration + Headers struct { + UserAgent string `json:"user_agent"` // Default: "GitHubCopilotChat/0.29.1" + EditorVersion string `json:"editor_version"` // Default: "vscode/1.102.3" + EditorPluginVersion string `json:"editor_plugin_version"` // Default: "copilot-chat/0.29.1" + CopilotIntegrationID string `json:"copilot_integration_id"` // Default: "vscode-chat" + OpenaiIntent string `json:"openai_intent"` // Default: "conversation-edits" + XInitiator string `json:"x_initiator"` // Default: "user" + } `json:"headers"` + + // CORS configuration + CORS struct { + AllowedOrigins []string `json:"allowed_origins"` // Default: ["*"] (permissive) + AllowedHeaders []string `json:"allowed_headers"` // Default: ["*"] + } `json:"cors"` + + // Timeout configurations (in seconds) + Timeouts struct { + HTTPClient int `json:"http_client"` // Default: 300s for streaming responses + ServerRead int `json:"server_read"` // Default: 30s for request reading + ServerWrite int `json:"server_write"` // Default: 300s for streaming responses + ServerIdle int `json:"server_idle"` // Default: 120s for idle connections + ProxyContext int `json:"proxy_context"` // Default: 300s for proxy request context + CircuitBreaker int `json:"circuit_breaker"` // Default: 30s for circuit breaker recovery + KeepAlive int `json:"keep_alive"` // Default: 30s for connection keep-alive + TLSHandshake int `json:"tls_handshake"` // Default: 10s for TLS handshake + DialTimeout int `json:"dial_timeout"` // Default: 10s for connection dialing + IdleConnTimeout int `json:"idle_conn_timeout"` // Default: 90s for idle connection timeout + } `json:"timeouts"` +} + +// GetConfigPath returns the path to the config file +func GetConfigPath() (string, error) { + usr, err := user.Current() + if err != nil { + return "", err + } + dir := filepath.Join(usr.HomeDir, configDirName) + if err := os.MkdirAll(dir, dirPerm); err != nil { + return "", err + } + return filepath.Join(dir, configFileName), nil +} + +// LoadConfig loads the configuration from file and environment variables +func LoadConfig(skipTokenValidation ...bool) (*Config, error) { + path, err := GetConfigPath() + if err != nil { + return nil, err + } + + // Start with default config + cfg := &Config{Port: defaultServerPort} + SetDefaultTimeouts(cfg) + SetDefaultHeaders(cfg) + SetDefaultCORS(cfg) + + // Load from file if it exists + file, err := os.Open(path) + if err == nil { + defer func() { + if closeErr := file.Close(); closeErr != nil { + Error("Failed to close config file", "error", closeErr) + } + }() + if err := json.NewDecoder(file).Decode(cfg); err != nil { + return nil, err + } + } + + // Override with environment variables if present + if port := os.Getenv("COPILOT_PORT"); port != "" { + if p, err := strconv.Atoi(port); err == nil { + cfg.Port = p + } + } + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + cfg.GitHubToken = token + } + if token := os.Getenv("COPILOT_TOKEN"); token != "" { + cfg.CopilotToken = token + } + + // Set default port if still not specified + if cfg.Port == 0 { + cfg.Port = defaultServerPort + } + + // Validate configuration + skip := len(skipTokenValidation) > 0 && skipTokenValidation[0] + if skip { + if err := cfg.validateCore(); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } + } else { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } + } + + return cfg, nil +} + +// SetDefaultTimeouts sets default timeout values if they are zero +func SetDefaultTimeouts(cfg *Config) { + if cfg.Timeouts.HTTPClient == 0 { + cfg.Timeouts.HTTPClient = defaultHTTPClientTimeout + } + if cfg.Timeouts.ServerRead == 0 { + cfg.Timeouts.ServerRead = defaultServerReadTimeout + } + if cfg.Timeouts.ServerWrite == 0 { + cfg.Timeouts.ServerWrite = defaultServerWriteTimeout + } + if cfg.Timeouts.ServerIdle == 0 { + cfg.Timeouts.ServerIdle = defaultServerIdleTimeout + } + if cfg.Timeouts.ProxyContext == 0 { + cfg.Timeouts.ProxyContext = defaultProxyContextTimeout + } + if cfg.Timeouts.CircuitBreaker == 0 { + cfg.Timeouts.CircuitBreaker = defaultCircuitBreakerTimeout + } + if cfg.Timeouts.KeepAlive == 0 { + cfg.Timeouts.KeepAlive = defaultKeepAliveTimeout + } + if cfg.Timeouts.TLSHandshake == 0 { + cfg.Timeouts.TLSHandshake = defaultTLSHandshakeTimeout + } + if cfg.Timeouts.DialTimeout == 0 { + cfg.Timeouts.DialTimeout = defaultDialTimeout + } + if cfg.Timeouts.IdleConnTimeout == 0 { + cfg.Timeouts.IdleConnTimeout = defaultIdleConnTimeout + } +} + +// SetDefaultHeaders sets default header values if they are empty +func SetDefaultHeaders(cfg *Config) { + if cfg.Headers.UserAgent == "" { + cfg.Headers.UserAgent = defaultUserAgent + } + if cfg.Headers.EditorVersion == "" { + cfg.Headers.EditorVersion = defaultEditorVersion + } + if cfg.Headers.EditorPluginVersion == "" { + cfg.Headers.EditorPluginVersion = defaultEditorPluginVersion + } + if cfg.Headers.CopilotIntegrationID == "" { + cfg.Headers.CopilotIntegrationID = defaultCopilotIntegrationID + } + if cfg.Headers.OpenaiIntent == "" { + cfg.Headers.OpenaiIntent = defaultOpenaiIntent + } + if cfg.Headers.XInitiator == "" { + cfg.Headers.XInitiator = defaultXInitiator + } +} + +// SetDefaultCORS sets default CORS values if they are empty +func SetDefaultCORS(cfg *Config) { + if len(cfg.CORS.AllowedOrigins) == 0 { + cfg.CORS.AllowedOrigins = []string{"*"} + } + if len(cfg.CORS.AllowedHeaders) == 0 { + cfg.CORS.AllowedHeaders = []string{"*"} + } +} + +// Validate checks the configuration for correctness. +func (c *Config) Validate() error { + if err := c.validatePort(); err != nil { + return err + } + if err := c.validateTokens(); 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 +} + +func (c *Config) validatePort() error { + if c.Port < minPortNumber || c.Port > maxPortNumber { + return NewValidationError("port", c.Port, fmt.Sprintf("must be between %d and %d", minPortNumber, maxPortNumber), nil) + } + return nil +} + +func (c *Config) validateTokens() error { + if c.GitHubToken == "" && c.CopilotToken == "" { + return ErrMissingTokens + } + return nil +} + +func (c *Config) validateTimeouts() error { + if err := c.validateHTTPClientTimeout(); err != nil { + return err + } + if err := c.validateServerReadTimeout(); err != nil { + return err + } + if err := c.validateServerWriteTimeout(); err != nil { + return err + } + if err := c.validateServerIdleTimeout(); err != nil { + return err + } + if err := c.validateProxyContextTimeout(); err != nil { + return err + } + if err := c.validateCircuitBreakerTimeout(); err != nil { + return err + } + if err := c.validateKeepAliveTimeout(); err != nil { + return err + } + if err := c.validateTLSHandshakeTimeout(); err != nil { + return err + } + if err := c.validateDialTimeout(); err != nil { + return err + } + if err := c.validateIdleConnTimeout(); err != nil { + return err + } + return nil +} + +func (c *Config) validateHTTPClientTimeout() error { + if c.Timeouts.HTTPClient < minTimeout || c.Timeouts.HTTPClient > maxLongTimeout { + return NewValidationError("timeouts.http_client", c.Timeouts.HTTPClient, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxLongTimeout), nil) + } + return nil +} + +func (c *Config) validateServerReadTimeout() error { + if c.Timeouts.ServerRead < minTimeout || c.Timeouts.ServerRead > maxShortTimeout { + return NewValidationError("timeouts.server_read", c.Timeouts.ServerRead, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxShortTimeout), nil) + } + return nil +} + +func (c *Config) validateServerWriteTimeout() error { + if c.Timeouts.ServerWrite < minTimeout || c.Timeouts.ServerWrite > maxLongTimeout { + return NewValidationError("timeouts.server_write", c.Timeouts.ServerWrite, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxLongTimeout), nil) + } + return nil +} + +func (c *Config) validateServerIdleTimeout() error { + if c.Timeouts.ServerIdle < minTimeout || c.Timeouts.ServerIdle > maxLongTimeout { + return NewValidationError("timeouts.server_idle", c.Timeouts.ServerIdle, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxLongTimeout), nil) + } + return nil +} + +func (c *Config) validateProxyContextTimeout() error { + if c.Timeouts.ProxyContext < minTimeout || c.Timeouts.ProxyContext > maxLongTimeout { + return NewValidationError("timeouts.proxy_context", c.Timeouts.ProxyContext, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxLongTimeout), nil) + } + return nil +} + +func (c *Config) validateCircuitBreakerTimeout() error { + if c.Timeouts.CircuitBreaker < minTimeout || c.Timeouts.CircuitBreaker > maxShortTimeout { + return NewValidationError("timeouts.circuit_breaker", c.Timeouts.CircuitBreaker, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxShortTimeout), nil) + } + return nil +} + +func (c *Config) validateKeepAliveTimeout() error { + if c.Timeouts.KeepAlive < minTimeout || c.Timeouts.KeepAlive > maxShortTimeout { + return NewValidationError("timeouts.keep_alive", c.Timeouts.KeepAlive, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxShortTimeout), nil) + } + return nil +} + +func (c *Config) validateTLSHandshakeTimeout() error { + if c.Timeouts.TLSHandshake < minTimeout || c.Timeouts.TLSHandshake > maxShortTimeout { + return NewValidationError("timeouts.tls_handshake", c.Timeouts.TLSHandshake, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxShortTimeout), nil) + } + return nil +} + +func (c *Config) validateDialTimeout() error { + if c.Timeouts.DialTimeout < minTimeout || c.Timeouts.DialTimeout > maxShortTimeout { + return NewValidationError("timeouts.dial_timeout", c.Timeouts.DialTimeout, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxShortTimeout), nil) + } + return nil +} + +func (c *Config) validateIdleConnTimeout() error { + if c.Timeouts.IdleConnTimeout < minTimeout || c.Timeouts.IdleConnTimeout > maxLongTimeout { + return NewValidationError("timeouts.idle_conn_timeout", c.Timeouts.IdleConnTimeout, + fmt.Sprintf("must be between %d and %d seconds", minTimeout, maxLongTimeout), nil) + } + return nil +} + +func (c *Config) validateHeaders() error { + if c.Headers.UserAgent == "" { + return NewValidationError("headers.user_agent", "", "user_agent cannot be empty", nil) + } + if c.Headers.EditorVersion == "" { + return NewValidationError("headers.editor_version", "", "editor_version cannot be empty", nil) + } + if c.Headers.EditorPluginVersion == "" { + return NewValidationError("headers.editor_plugin_version", "", "editor_plugin_version cannot be empty", nil) + } + if c.Headers.CopilotIntegrationID == "" { + return NewValidationError("headers.copilot_integration_id", "", "copilot_integration_id cannot be empty", nil) + } + if c.Headers.OpenaiIntent == "" { + return NewValidationError("headers.openai_intent", "", "openai_intent cannot be empty", nil) + } + if c.Headers.XInitiator == "" { + return NewValidationError("headers.x_initiator", "", "x_initiator cannot be empty", nil) + } + return nil +} + +func (c *Config) validateCORS() error { + if len(c.CORS.AllowedOrigins) == 0 { + return NewValidationError("cors.allowed_origins", "", "allowed_origins cannot be empty", nil) + } + if len(c.CORS.AllowedHeaders) == 0 { + return NewValidationError("cors.allowed_headers", "", "allowed_headers cannot be empty", nil) + } + for _, origin := range c.CORS.AllowedOrigins { + if origin != "*" && origin != "" { + if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { + if !strings.HasPrefix(origin, "localhost") && !strings.HasPrefix(origin, "127.0.0.1") { + Warn("CORS origin may not be valid URL format", "origin", origin) + } + } + } + } + return nil +} + +// SaveConfig saves the configuration to file +func (c *Config) SaveConfig(pathOverride ...string) error { + var path string + var err error + if len(pathOverride) > 0 && pathOverride[0] != "" { + path = pathOverride[0] + } else { + path, err = GetConfigPath() + if err != nil { + return err + } + } + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + Error("Failed to close config file", "error", closeErr) + } + }() + 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 new file mode 100644 index 0000000..6b33ff7 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,319 @@ +package internal_test + +import ( + "os" + "testing" + + "github.com/privapps/github-copilot-svcs/internal" +) + +func TestConfigValidation(t *testing.T) { + t.Run("valid config passes validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + err := cfg.Validate() + if err != nil { + t.Errorf("Expected valid config to pass validation, got error: %v", err) + } + }) + + t.Run("invalid port fails validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 99999, // Invalid port + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + err := cfg.Validate() + if err == nil { + t.Error("Expected invalid port to fail validation") + } + }) + + t.Run("negative port fails validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: -1, // Invalid port + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + err := cfg.Validate() + if err == nil { + t.Error("Expected negative port to fail validation") + } + }) + + t.Run("missing tokens fails validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + // No tokens provided + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + err := cfg.Validate() + 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) { + cfg := &internal.Config{ + Port: 8081, + CopilotToken: "test-copilot-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + err := cfg.Validate() + if err != nil { + t.Errorf("Expected valid config with copilot token to pass validation, got error: %v", err) + } + }) + + t.Run("invalid timeout values fail validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + // Test invalid HTTP client timeout + original := cfg.Timeouts.HTTPClient + cfg.Timeouts.HTTPClient = -1 + err := cfg.Validate() + if err == nil { + t.Error("Expected negative HTTP client timeout to fail validation") + } + cfg.Timeouts.HTTPClient = original + + // Test invalid server read timeout + cfg.Timeouts.ServerRead = 1000000 // Too large + err = cfg.Validate() + if err == nil { + t.Error("Expected too large server read timeout to fail validation") + } + }) + + t.Run("empty headers fail validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + // Test empty user agent + original := cfg.Headers.UserAgent + cfg.Headers.UserAgent = "" + err := cfg.Validate() + if err == nil { + t.Error("Expected empty user agent to fail validation") + } + cfg.Headers.UserAgent = original + }) + + t.Run("empty CORS configuration fails validation", func(t *testing.T) { + cfg := &internal.Config{ + Port: 8081, + GitHubToken: "test-token", + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + // Test empty allowed origins + original := cfg.CORS.AllowedOrigins + cfg.CORS.AllowedOrigins = []string{} + err := cfg.Validate() + if err == nil { + t.Error("Expected empty CORS allowed origins to fail validation") + } + cfg.CORS.AllowedOrigins = original + }) +} + +func TestLoadConfig(t *testing.T) { + t.Run("loads config with validation", func(t *testing.T) { + // Save original environment + originalPort := os.Getenv("COPILOT_PORT") + originalToken := os.Getenv("GITHUB_TOKEN") + + // Set test environment + os.Setenv("COPILOT_PORT", "8081") + os.Setenv("GITHUB_TOKEN", "test-token") + + // Restore environment + defer func() { + if originalPort != "" { + os.Setenv("COPILOT_PORT", originalPort) + } else { + os.Unsetenv("COPILOT_PORT") + } + + if originalToken != "" { + os.Setenv("GITHUB_TOKEN", originalToken) + } else { + os.Unsetenv("GITHUB_TOKEN") + } + }() + + cfg, err := internal.LoadConfig() + if err != nil { + t.Errorf("Expected successful config load, got error: %v", err) + } + + if cfg.Port != 8081 { + t.Errorf("Expected port 8081, got %d", cfg.Port) + } + + if cfg.GitHubToken != "test-token" { + t.Errorf("Expected GitHub token 'test-token', got '%s'", cfg.GitHubToken) + } + }) + + t.Run("fails with invalid port in environment", func(t *testing.T) { + // Save original environment + originalPort := os.Getenv("COPILOT_PORT") + originalToken := os.Getenv("GITHUB_TOKEN") + + // Set test environment with invalid port + os.Setenv("COPILOT_PORT", "99999") + os.Setenv("GITHUB_TOKEN", "test-token") + + // Restore environment + defer func() { + if originalPort != "" { + os.Setenv("COPILOT_PORT", originalPort) + } else { + os.Unsetenv("COPILOT_PORT") + } + + if originalToken != "" { + os.Setenv("GITHUB_TOKEN", originalToken) + } else { + os.Unsetenv("GITHUB_TOKEN") + } + }() + + _, err := internal.LoadConfig() + if err == nil { + t.Error("Expected config load to fail with invalid port") + } + }) +} + +func TestSetDefaultValues(t *testing.T) { + t.Run("sets default timeouts correctly", func(t *testing.T) { + cfg := &internal.Config{} + internal.SetDefaultTimeouts(cfg) + + // Check that all timeouts have reasonable default values + if cfg.Timeouts.HTTPClient == 0 { + t.Error("Expected HTTPClient timeout to have default value") + } + if cfg.Timeouts.ServerRead == 0 { + t.Error("Expected ServerRead timeout to have default value") + } + if cfg.Timeouts.ServerWrite == 0 { + t.Error("Expected ServerWrite timeout to have default value") + } + }) + + t.Run("sets default headers correctly", func(t *testing.T) { + cfg := &internal.Config{} + internal.SetDefaultHeaders(cfg) + + // Check that all headers have default values + if cfg.Headers.UserAgent == "" { + t.Error("Expected UserAgent to have default value") + } + if cfg.Headers.EditorVersion == "" { + t.Error("Expected EditorVersion to have default value") + } + if cfg.Headers.EditorPluginVersion == "" { + t.Error("Expected EditorPluginVersion to have default value") + } + }) + + t.Run("sets default CORS correctly", func(t *testing.T) { + cfg := &internal.Config{} + internal.SetDefaultCORS(cfg) + + // Check that CORS has default values + if len(cfg.CORS.AllowedOrigins) == 0 { + t.Error("Expected AllowedOrigins to have default value") + } + if len(cfg.CORS.AllowedHeaders) == 0 { + t.Error("Expected AllowedHeaders to have default value") + } + }) +} +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 new file mode 100644 index 0000000..6028701 --- /dev/null +++ b/internal/errors.go @@ -0,0 +1,201 @@ +// Package internal provides error types and helpers for github-copilot-svcs. +package internal + +import ( + "fmt" + "net/http" +) + +type ( + // AuthenticationError ... + AuthenticationError struct { + Message string + Err error + } + + // ConfigurationError ... + ConfigurationError struct { + Field string + Value interface{} + Message string + Err error + } + + // NetworkError ... + NetworkError struct { + Operation string + URL string + Message string + Err error + } + + // ValidationError ... + ValidationError struct { + Field string + Value interface{} + Message string + Err error + } + + // ProxyError ... + ProxyError struct { + Operation string + Message string + Err error + } +) + +func (e *AuthenticationError) Error() string { + if e.Err != nil { + return fmt.Sprintf("authentication error: %s: %v", e.Message, e.Err) + } + return fmt.Sprintf("authentication error: %s", e.Message) +} + +func (e *AuthenticationError) Unwrap() error { + return e.Err +} + +func (e *ConfigurationError) Error() string { + if e.Err != nil { + return fmt.Sprintf("configuration error for %s=%v: %s: %v", e.Field, e.Value, e.Message, e.Err) + } + return fmt.Sprintf("configuration error for %s=%v: %s", e.Field, e.Value, e.Message) +} + +func (e *ConfigurationError) Unwrap() error { + return e.Err +} + +func (e *NetworkError) Error() string { + if e.Err != nil { + return fmt.Sprintf("network error during %s to %s: %s: %v", e.Operation, e.URL, e.Message, e.Err) + } + return fmt.Sprintf("network error during %s to %s: %s", e.Operation, e.URL, e.Message) +} + +func (e *NetworkError) Unwrap() error { + return e.Err +} + +func (e *ValidationError) Error() string { + if e.Err != nil { + return fmt.Sprintf("validation error for %s=%v: %s: %v", e.Field, e.Value, e.Message, e.Err) + } + return fmt.Sprintf("validation error for %s=%v: %s", e.Field, e.Value, e.Message) +} + +func (e *ValidationError) Unwrap() error { + return e.Err +} + +func (e *ProxyError) Error() string { + if e.Err != nil { + return fmt.Sprintf("proxy error during %s: %s: %v", e.Operation, e.Message, e.Err) + } + return fmt.Sprintf("proxy error during %s: %s", e.Operation, e.Message) +} + +func (e *ProxyError) Unwrap() error { + return e.Err +} + +// NewAuthError ... +func NewAuthError(message string, err error) *AuthenticationError { + return &AuthenticationError{Message: message, Err: err} +} + +// NewConfigError ... +func NewConfigError(field string, value interface{}, message string, err error) *ConfigurationError { + return &ConfigurationError{Field: field, Value: value, Message: message, Err: err} +} + +// NewNetworkError ... +func NewNetworkError(operation, url, message string, err error) *NetworkError { + return &NetworkError{Operation: operation, URL: url, Message: message, Err: err} +} + +// NewValidationError ... +func NewValidationError(field string, value interface{}, message string, err error) *ValidationError { + return &ValidationError{Field: field, Value: value, Message: message, Err: err} +} + +// NewProxyError ... +func NewProxyError(operation, message string, err error) *ProxyError { + return &ProxyError{Operation: operation, Message: message, Err: err} +} + +// WriteHTTPError ... +func WriteHTTPError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = fmt.Fprintf(w, `{"error": {"message": "%s", "type": "error", "code": %d}}`, message, statusCode) +} + +// WriteHTTPErrorWithDetails ... +func WriteHTTPErrorWithDetails(w http.ResponseWriter, statusCode int, errorType, message, details string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = fmt.Fprintf(w, `{"error": {"message": "%s", "type": "%s", "code": %d, "details": "%s"}}`, + message, errorType, statusCode, details) +} + +// WriteAuthenticationError ... +func WriteAuthenticationError(w http.ResponseWriter) { + WriteHTTPError(w, http.StatusUnauthorized, "Authentication required") +} + +// WriteAuthorizationError ... +func WriteAuthorizationError(w http.ResponseWriter) { + WriteHTTPError(w, http.StatusForbidden, "Insufficient permissions") +} + +// WriteValidationError ... +func WriteValidationError(w http.ResponseWriter, message string) { + WriteHTTPError(w, http.StatusBadRequest, message) +} + +// WriteInternalError ... +func WriteInternalError(w http.ResponseWriter) { + WriteHTTPError(w, http.StatusInternalServerError, "Internal server error") +} + +// WriteServiceUnavailableError ... +func WriteServiceUnavailableError(w http.ResponseWriter) { + WriteHTTPError(w, http.StatusServiceUnavailable, "Service temporarily unavailable") +} + +// WriteRateLimitError ... +func WriteRateLimitError(w http.ResponseWriter) { + WriteHTTPError(w, http.StatusTooManyRequests, "Rate limit exceeded") +} + +// IsAuthenticationError ... +func IsAuthenticationError(err error) bool { + _, ok := err.(*AuthenticationError) + return ok +} + +// IsConfigurationError ... +func IsConfigurationError(err error) bool { + _, ok := err.(*ConfigurationError) + return ok +} + +// IsNetworkError ... +func IsNetworkError(err error) bool { + _, ok := err.(*NetworkError) + return ok +} + +// IsValidationError ... +func IsValidationError(err error) bool { + _, ok := err.(*ValidationError) + return ok +} + +// IsProxyError ... +func IsProxyError(err error) bool { + _, ok := err.(*ProxyError) + return ok +} diff --git a/internal/errors_test.go b/internal/errors_test.go new file mode 100644 index 0000000..fc66f4f --- /dev/null +++ b/internal/errors_test.go @@ -0,0 +1,197 @@ +package internal + +import ( + "errors" + "net/http" + "testing" +) + +func TestNewAuthError(t *testing.T) { + err := NewAuthError("msg", errors.New("inner")) + if err.Message != "msg" || err.Err.Error() != "inner" { + t.Errorf("unexpected error: %+v", err) + } +} + +func TestNewConfigError(t *testing.T) { + err := NewConfigError("field", "val", "msg", errors.New("inner")) + if err.Field != "field" || err.Value != "val" || err.Message != "msg" || err.Err.Error() != "inner" { + t.Errorf("unexpected error: %+v", err) + } +} + +func TestNewNetworkError(t *testing.T) { + err := NewNetworkError("op", "url", "msg", errors.New("inner")) + if err.Operation != "op" || err.URL != "url" || err.Message != "msg" || err.Err.Error() != "inner" { + t.Errorf("unexpected error: %+v", err) + } +} + +func TestNewValidationError(t *testing.T) { + err := NewValidationError("field", "val", "msg", errors.New("inner")) + if err.Field != "field" || err.Value != "val" || err.Message != "msg" || err.Err.Error() != "inner" { + t.Errorf("unexpected error: %+v", err) + } +} + +func TestNewProxyError(t *testing.T) { + err := NewProxyError("op", "msg", errors.New("inner")) + if err.Operation != "op" || err.Message != "msg" || err.Err.Error() != "inner" { + t.Errorf("unexpected error: %+v", err) + } +} + +func TestErrorImplementations(t *testing.T) { + auth := NewAuthError("msg", errors.New("inner")) + if auth.Error() == "" { + t.Error("expected non-empty error string") + } + if !errors.Is(auth, auth.Err) { + t.Error("expected errors.Is to match inner error") + } + + conf := NewConfigError("f", "v", "m", errors.New("inner")) + if conf.Error() == "" { + t.Error("expected non-empty error string") + } + if !errors.Is(conf, conf.Err) { + t.Error("expected errors.Is to match inner error") + } + + neterr := NewNetworkError("op", "url", "m", errors.New("inner")) + if neterr.Error() == "" { + t.Error("expected non-empty error string") + } + if !errors.Is(neterr, neterr.Err) { + t.Error("expected errors.Is to match inner error") + } + + val := NewValidationError("f", "v", "m", errors.New("inner")) + if val.Error() == "" { + t.Error("expected non-empty error string") + } + if !errors.Is(val, val.Err) { + t.Error("expected errors.Is to match inner error") + } + + proxy := NewProxyError("op", "m", errors.New("inner")) + if proxy.Error() == "" { + t.Error("expected non-empty error string") + } + if !errors.Is(proxy, proxy.Err) { + t.Error("expected errors.Is to match inner error") + } +} + +func TestWriteHTTPError(t *testing.T) { + w := &mockResponseWriter{} + WriteHTTPError(w, http.StatusBadRequest, "bad request") + if w.status != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.status) + } + if w.header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json header") + } +} + +func TestWriteHTTPErrorWithDetails(t *testing.T) { + w := &mockResponseWriter{} + WriteHTTPErrorWithDetails(w, http.StatusForbidden, "auth", "forbidden", "details") + if w.status != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, w.status) + } + if w.header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json header") + } +} + +func TestWriteAuthenticationError(t *testing.T) { + w := &mockResponseWriter{} + WriteAuthenticationError(w) + if w.status != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.status) + } +} + +func TestWriteAuthorizationError(t *testing.T) { + w := &mockResponseWriter{} + WriteAuthorizationError(w) + if w.status != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, w.status) + } +} + +func TestWriteValidationError(t *testing.T) { + w := &mockResponseWriter{} + WriteValidationError(w, "validation error") + if w.status != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.status) + } +} + +func TestWriteInternalError(t *testing.T) { + w := &mockResponseWriter{} + WriteInternalError(w) + if w.status != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.status) + } +} + +func TestWriteServiceUnavailableError(t *testing.T) { + w := &mockResponseWriter{} + WriteServiceUnavailableError(w) + if w.status != http.StatusServiceUnavailable { + t.Errorf("expected status %d, got %d", http.StatusServiceUnavailable, w.status) + } +} + +func TestWriteRateLimitError(t *testing.T) { + w := &mockResponseWriter{} + WriteRateLimitError(w) + if w.status != http.StatusTooManyRequests { + t.Errorf("expected status %d, got %d", http.StatusTooManyRequests, w.status) + } +} + +func TestErrorTypeChecks(t *testing.T) { + auth := NewAuthError("msg", nil) + if !IsAuthenticationError(auth) { + t.Error("expected IsAuthenticationError true") + } + conf := NewConfigError("f", "v", "m", nil) + if !IsConfigurationError(conf) { + t.Error("expected IsConfigurationError true") + } + neterr := NewNetworkError("op", "url", "m", nil) + if !IsNetworkError(neterr) { + t.Error("expected IsNetworkError true") + } + val := NewValidationError("f", "v", "m", nil) + if !IsValidationError(val) { + t.Error("expected IsValidationError true") + } + proxy := NewProxyError("op", "m", nil) + if !IsProxyError(proxy) { + t.Error("expected IsProxyError true") + } +} + +type mockResponseWriter struct { + header http.Header + status int +} + +func (m *mockResponseWriter) Header() http.Header { + if m.header == nil { + m.header = make(http.Header) + } + return m.header +} + +func (m *mockResponseWriter) Write(b []byte) (int, error) { + return len(b), nil +} + +func (m *mockResponseWriter) WriteHeader(statusCode int) { + m.status = statusCode +} diff --git a/internal/health.go b/internal/health.go new file mode 100644 index 0000000..2e2b1c4 --- /dev/null +++ b/internal/health.go @@ -0,0 +1,283 @@ +// Package internal provides health check logic for github-copilot-svcs. +package internal + +import ( + "context" + "encoding/json" + "net/http" + "runtime" + "time" +) + +// Constants for health checking +const ( + healthCheckTimeout = 10 * time.Second + memoryThresholdMB = 1024 // 1GB in MB + memoryWarningGB = 1024 * 1024 * 1024 // 1GB + memoryCriticalGB = 2 * 1024 * 1024 * 1024 // 2GB + goroutineWarning = 1000 + goroutineCritical = 5000 + bytesToMB = 1024 * 1024 + percentMultiplier = 100 +) + +// HealthStatus represents the overall health status +type HealthStatus string + +const ( + // StatusHealthy indicates the service is healthy. + StatusHealthy HealthStatus = "healthy" + // StatusDegraded indicates the service is degraded. + StatusDegraded HealthStatus = "degraded" + // StatusUnhealthy indicates the service is unhealthy. + StatusUnhealthy HealthStatus = "unhealthy" +) + +// HealthCheck represents a single health check +type HealthCheck struct { + Name string `json:"name"` + Status HealthStatus `json:"status"` + Message string `json:"message,omitempty"` + Duration time.Duration `json:"duration"` + LastChecked time.Time `json:"last_checked"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// HealthResponse represents the complete health response +type HealthResponse struct { + Status HealthStatus `json:"status"` + Service string `json:"service"` + Version string `json:"version,omitempty"` + Timestamp time.Time `json:"timestamp"` + Uptime time.Duration `json:"uptime"` + Checks []HealthCheck `json:"checks"` + System SystemMetrics `json:"system"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// SystemMetrics represents system-level metrics +type SystemMetrics struct { + Memory MemoryMetrics `json:"memory"` + Goroutines int `json:"goroutines"` + CGoCalls int64 `json:"cgo_calls"` + NumCPU int `json:"num_cpu"` + GOMAXPROCS int `json:"gomaxprocs"` +} + +// MemoryMetrics represents memory usage metrics +type MemoryMetrics struct { + Alloc uint64 `json:"alloc"` // bytes allocated and still in use + TotalAlloc uint64 `json:"total_alloc"` // bytes allocated (even if freed) + Sys uint64 `json:"sys"` // bytes obtained from system + Lookups uint64 `json:"lookups"` // number of pointer lookups + Mallocs uint64 `json:"mallocs"` // number of mallocs + Frees uint64 `json:"frees"` // number of frees + HeapAlloc uint64 `json:"heap_alloc"` // bytes allocated and still in use + HeapSys uint64 `json:"heap_sys"` // bytes obtained from system + HeapIdle uint64 `json:"heap_idle"` // bytes in idle spans + HeapInuse uint64 `json:"heap_inuse"` // bytes in non-idle span + HeapReleased uint64 `json:"heap_released"` // bytes released to the OS + GCCPUPercent float64 `json:"gc_cpu_percent"` // percentage of CPU time spent in GC +} + +// HealthChecker manages health checks +type HealthChecker struct { + startTime time.Time + httpClient *http.Client + version string + checks []HealthCheckFunc +} + +// HealthCheckFunc represents a health check function +type HealthCheckFunc func(ctx context.Context) HealthCheck + +// NewHealthChecker creates a new health checker +func NewHealthChecker(httpClient *http.Client, version string) *HealthChecker { + hc := &HealthChecker{ + startTime: time.Now(), + httpClient: httpClient, + version: version, + checks: make([]HealthCheckFunc, 0), + } + + // Add default health checks + hc.AddCheck(hc.checkMemory) + hc.AddCheck(hc.checkGoroutines) + + return hc +} + +// AddCheck adds a health check function +// AddCheck adds a health check function. +func (h *HealthChecker) AddCheck(check HealthCheckFunc) { + h.checks = append(h.checks, check) +} + +// CheckHealth performs all health checks and returns the overall status. +func (h *HealthChecker) CheckHealth(ctx context.Context) *HealthResponse { + start := time.Now() + + // Run all checks + checks := make([]HealthCheck, 0, len(h.checks)) + overallStatus := StatusHealthy + + for _, checkFunc := range h.checks { + check := checkFunc(ctx) + checks = append(checks, check) + + // Determine overall status + if check.Status == StatusUnhealthy { + overallStatus = StatusUnhealthy + } else if check.Status == StatusDegraded && overallStatus == StatusHealthy { + overallStatus = StatusDegraded + } + } + + // Collect system metrics + systemMetrics := h.collectSystemMetrics() + + response := &HealthResponse{ + Status: overallStatus, + Service: "github-copilot-svcs", + Version: h.version, + Timestamp: time.Now(), + Uptime: time.Since(h.startTime), + Checks: checks, + System: systemMetrics, + Details: map[string]interface{}{ + "health_check_duration": time.Since(start), + }, + } + + return response +} + +// Handler ... +func (h *HealthChecker) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout) + defer cancel() + + health := h.CheckHealth(ctx) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + + // Set HTTP status based on health status + switch health.Status { + case StatusHealthy: + w.WriteHeader(http.StatusOK) + case StatusDegraded: + w.WriteHeader(http.StatusOK) // Still OK but degraded + case StatusUnhealthy: + w.WriteHeader(http.StatusServiceUnavailable) + } + + if err := json.NewEncoder(w).Encode(health); err != nil { + Error("Failed to encode health response", "error", err) + WriteInternalError(w) + } + } +} + +// Default health checks +// checkMemory checks memory usage and returns a HealthCheck. +func (h *HealthChecker) checkMemory(_ context.Context) HealthCheck { + start := time.Now() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + status := StatusHealthy + message := "Memory usage normal" + + // Check if memory usage is concerning + if m.Alloc > memoryWarningGB { + status = StatusDegraded + message = "High memory usage detected" + } + + // Check if memory usage is critical + if m.Alloc > memoryCriticalGB { + status = StatusUnhealthy + message = "Critical memory usage detected" + } + + return HealthCheck{ + Name: "memory", + Status: status, + Message: message, + Duration: time.Since(start), + LastChecked: time.Now(), + Details: map[string]interface{}{ + "alloc_mb": m.Alloc / bytesToMB, + "sys_mb": m.Sys / bytesToMB, + "heap_alloc_mb": m.HeapAlloc / bytesToMB, + "num_gc": m.NumGC, + }, + } +} + +// checkGoroutines checks goroutine count and returns a HealthCheck. +// checkGoroutines checks goroutine count and returns a HealthCheck. +// checkGoroutines checks goroutine count and returns a HealthCheck. +// checkGoroutines checks goroutine count and returns a HealthCheck. +func (h *HealthChecker) checkGoroutines(_ context.Context) HealthCheck { + start := time.Now() + + numGoroutines := runtime.NumGoroutine() + + status := StatusHealthy + message := "Goroutine count normal" + + // Check if goroutine count is concerning + if numGoroutines > goroutineWarning { + status = StatusDegraded + message = "High goroutine count detected" + } + + // Check if goroutine count is critical + if numGoroutines > goroutineCritical { + status = StatusUnhealthy + message = "Critical goroutine count detected" + } + + return HealthCheck{ + Name: "goroutines", + Status: status, + Message: message, + Duration: time.Since(start), + LastChecked: time.Now(), + Details: map[string]interface{}{ + "count": numGoroutines, + }, + } +} + +// collectSystemMetrics collects system metrics and returns a SystemMetrics struct. +// collectSystemMetrics collects system metrics and returns a SystemMetrics struct. +func (h *HealthChecker) collectSystemMetrics() SystemMetrics { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return SystemMetrics{ + Memory: MemoryMetrics{ + Alloc: m.Alloc, + TotalAlloc: m.TotalAlloc, + Sys: m.Sys, + Lookups: m.Lookups, + Mallocs: m.Mallocs, + Frees: m.Frees, + HeapAlloc: m.HeapAlloc, + HeapSys: m.HeapSys, + HeapIdle: m.HeapIdle, + HeapInuse: m.HeapInuse, + HeapReleased: m.HeapReleased, + GCCPUPercent: m.GCCPUFraction * percentMultiplier, + }, + Goroutines: runtime.NumGoroutine(), + CGoCalls: runtime.NumCgoCall(), + NumCPU: runtime.NumCPU(), + GOMAXPROCS: runtime.GOMAXPROCS(0), + } +} diff --git a/internal/logger.go b/internal/logger.go new file mode 100644 index 0000000..d57e8aa --- /dev/null +++ b/internal/logger.go @@ -0,0 +1,122 @@ +package internal + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" +) + +// DenseTextHandler outputs only values, space-separated, in a fixed order. +type DenseTextHandler struct { + level slog.Level +} + +// Enabled reports whether the handler is enabled for the given level. +func (h *DenseTextHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle formats the log record as dense values and writes to stdout. +func (h *DenseTextHandler) Handle(_ context.Context, r slog.Record) error { + var b strings.Builder + b.WriteString(r.Time.Format(time.RFC3339)) + b.WriteString(" ") + b.WriteString(r.Level.String()) + b.WriteString(" ") + b.WriteString(fmt.Sprintf("%q\t", r.Message)) + r.Attrs(func(a slog.Attr) bool { + b.WriteString(" ") + switch v := a.Value.Any().(type) { + case string: + b.WriteString(v) + default: + b.WriteString(fmt.Sprintf("%v", v)) + } + return true + }) + b.WriteString("\n") + _, err := os.Stdout.WriteString(b.String()) + return err +} + +// 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 } + +const ( + defaultLogLevel = "info" +) + +// Logger wraps slog.Logger for structured logging +type Logger struct { + *slog.Logger +} + +// NewLogger creates a new logger with the specified level +func NewLogger(level string) *Logger { + var logLevel slog.Level + switch strings.ToLower(level) { + case "debug": + logLevel = slog.LevelDebug + case defaultLogLevel: + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + handler := &DenseTextHandler{level: logLevel} + return &Logger{slog.New(handler)} +} + +var logger *Logger + +// Init initializes the global logger from environment variable +func Init() { + logLevel := os.Getenv("LOG_LEVEL") + if logLevel == "" { + logLevel = defaultLogLevel + } + logger = NewLogger(logLevel) +} + +// Debug logs a debug message +func Debug(msg string, args ...any) { + if logger != nil { + logger.Debug(msg, args...) + } +} + +// Info logs an info message +func Info(msg string, args ...any) { + if logger != nil { + logger.Info(msg, args...) + } +} + +// Warn logs a warning message +func Warn(msg string, args ...any) { + if logger != nil { + logger.Warn(msg, args...) + } +} + +// Error logs an error message +func Error(msg string, args ...any) { + if logger != nil { + logger.Error(msg, args...) + } +} + +// GetLogger returns the global logger instance +func GetLogger() *Logger { + return logger +} diff --git a/internal/logger_test.go b/internal/logger_test.go new file mode 100644 index 0000000..1726d19 --- /dev/null +++ b/internal/logger_test.go @@ -0,0 +1,12 @@ +package internal + +import ( + "testing" +) + +func TestNewLogger(t *testing.T) { + logger := NewLogger("info") + if logger == nil { + t.Error("NewLogger returned nil") + } +} diff --git a/internal/middleware.go b/internal/middleware.go new file mode 100644 index 0000000..fec18cf --- /dev/null +++ b/internal/middleware.go @@ -0,0 +1,257 @@ +// Package internal provides HTTP middleware for github-copilot-svcs. +package internal + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "time" +) + +// HTTP status code constants +const ( + statusServerError = 500 + statusClientError = 400 +) + +// LoggingResponseWriter wraps http.ResponseWriter to capture response data and status code. +type LoggingResponseWriter struct { + http.ResponseWriter + statusCode int + body *bytes.Buffer +} + +// NewLoggingResponseWriter ... +func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter { + return &LoggingResponseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + body: bytes.NewBuffer(nil), + } +} + +// WriteHeader ... +func (lrw *LoggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +func (lrw *LoggingResponseWriter) Write(body []byte) (int, error) { + // Write to both the original response and our buffer + lrw.body.Write(body) + return lrw.ResponseWriter.Write(body) +} + +// Hijack ... +func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// StatusCode ... +func (lrw *LoggingResponseWriter) StatusCode() int { + return lrw.statusCode +} + +// Body ... +func (lrw *LoggingResponseWriter) Body() []byte { + return lrw.body.Bytes() +} + +// LoggingMiddleware logs HTTP requests and responses, including status code and duration. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create logging response writer + lrw := NewLoggingResponseWriter(w) + + // Read and store request body for logging (if reasonable size) + var requestBody []byte + if r.Body != nil && r.ContentLength > 0 && r.ContentLength < 1024*1024 { // Max 1MB for logging + requestBody, _ = io.ReadAll(r.Body) + 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 + 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) + + // Calculate duration + duration := time.Since(start) + + // Determine log level based on status code + statusCode := lrw.StatusCode() + responseSize := len(lrw.Body()) + + logArgs := []interface{}{ + "method", r.Method, + "url", r.URL.String(), + "status_code", statusCode, + "duration_ms", duration.Milliseconds(), + "response_size", responseSize, + "remote_addr", getClientIP(r), + } + + // Log response with appropriate level + switch { + case statusCode >= statusServerError: + Error("HTTP Response", logArgs...) + case statusCode >= statusClientError: + Warn("HTTP Response", logArgs...) + default: + Info("HTTP Response", logArgs...) + } + + // Log response body for debugging if it's small and there was an error + if statusCode >= 400 && responseSize > 0 && responseSize < 1024 { + Debug("HTTP Response Body", "body", string(lrw.Body())) + } + }) +} + +// RecoveryMiddleware ... +func RecoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + Error("HTTP Handler Panic", + "error", err, + "method", r.Method, + "url", r.URL.String(), + "remote_addr", getClientIP(r), + ) + + WriteInternalError(w) + } + }() + next.ServeHTTP(w, r) + }) +} + +// CORSMiddleware ... +func CORSMiddleware(config *Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Set CORS headers based on configuration + if len(config.CORS.AllowedOrigins) > 0 { + if containsOrigin(config.CORS.AllowedOrigins, origin) || containsOrigin(config.CORS.AllowedOrigins, "*") { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + } + + if len(config.CORS.AllowedHeaders) > 0 { + w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.CORS.AllowedHeaders, ", ")) + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// SecurityHeadersMiddleware ... +func SecurityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + + // Only set HSTS for HTTPS requests + if r.TLS != nil { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + + next.ServeHTTP(w, r) + }) +} + +// TimeoutMiddleware sets a timeout for HTTP requests using http.TimeoutHandler. +func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.TimeoutHandler(next, timeout, "Request timeout") + } +} + +// Helper functions +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header (proxy) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // Take the first IP in the chain + if idx := strings.Index(xff, ","); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + return strings.TrimSpace(xff) + } + + // Check X-Real-IP header (nginx) + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + + // Fall back to RemoteAddr + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return ip + } + + return r.RemoteAddr +} + +func containsOrigin(origins []string, origin string) bool { + for _, o := range origins { + if o == origin || o == "*" { + return true + } + } + return false +} diff --git a/internal/models.go b/internal/models.go new file mode 100644 index 0000000..330f6af --- /dev/null +++ b/internal/models.go @@ -0,0 +1,256 @@ +// Package internal provides model-related logic for github-copilot-svcs. +package internal + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/privapps/github-copilot-svcs/pkg/transform" +) + +var ( + cachedModels *transform.ModelList + modelsMutex sync.RWMutex + modelsLoaded bool +) + +// ModelsDevResponse represents the structure from models.dev API +type ModelsDevResponse map[string]struct { + ID string `json:"id"` + Models map[string]struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + OwnedBy string `json:"owned_by,omitempty"` + } `json:"models"` +} + +// FetchFromModelsDev fetches models from models.dev API as fallback +func FetchFromModelsDev(httpClient *http.Client) (*transform.ModelList, error) { + resp, err := httpClient.Get("https://models.dev/api.json") + if err != nil { + return nil, err + } + defer func() { + 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) + } + + var providers ModelsDevResponse + if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil { + return nil, err + } + + // Extract GitHub Copilot models + copilotProvider, exists := providers["github-copilot"] + if !exists { + return nil, NewValidationError("provider", "github-copilot", "provider not found in models.dev response", nil) + } + + var models []transform.Model + for modelID, modelInfo := range copilotProvider.Models { + ownedBy := modelInfo.OwnedBy + if ownedBy == "" { + // Determine owner based on model name + switch { + case containsAny(modelInfo.Name, []string{"claude", "anthropic"}): + ownedBy = "anthropic" + case containsAny(modelInfo.Name, []string{"gpt", "o1", "o3", "o4", "openai"}): + ownedBy = "openai" + case containsAny(modelInfo.Name, []string{"gemini", "google"}): + ownedBy = "google" + default: + ownedBy = "github-copilot" + } + } + + models = append(models, transform.Model{ + ID: modelID, + Object: "model", + Created: time.Now().Unix(), + OwnedBy: ownedBy, + APIType: apiTypeForModel(modelID), + }) + } + + return &transform.ModelList{ + Object: "list", + Data: models, + }, nil +} + +// 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 { + 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 +func containsAny(text string, substrings []string) bool { + textLower := strings.ToLower(text) + for _, substr := range substrings { + if strings.Contains(textLower, strings.ToLower(substr)) { + return true + } + } + return false +} + +// ModelsService provides model operations +type ModelsService struct { + coalescingCache CoalescingCacheInterface + httpClient *http.Client +} + +// NewModelsService creates a new models service +func NewModelsService(cache CoalescingCacheInterface, httpClient *http.Client) *ModelsService { + return &ModelsService{ + coalescingCache: cache, + httpClient: httpClient, + } +} + +// CoalescingCacheInterface interface for request coalescing +type CoalescingCacheInterface interface { + GetRequestKey(method, path string, body interface{}) string + CoalesceRequest(key string, fn func() interface{}) interface{} +} // Handler returns an HTTP handler for the models endpoint. +// Handler returns an HTTP handler for the models endpoint. +func (s *ModelsService) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + // Use request coalescing for identical concurrent requests + requestKey := s.coalescingCache.GetRequestKey("GET", "/v1/models", nil) + + result := s.coalescingCache.CoalesceRequest(requestKey, func() interface{} { + // Check cache first + modelsMutex.RLock() + if modelsLoaded && cachedModels != nil { + modelsMutex.RUnlock() + return cachedModels + } + modelsMutex.RUnlock() + + // Load models if not cached + modelsMutex.Lock() + defer modelsMutex.Unlock() + + // Double-check in case another goroutine loaded while we waited + if modelsLoaded && cachedModels != nil { + return cachedModels + } + + Info("Loading models for the first time...") + + // Try models.dev API first (don't hit GitHub Copilot for models list) + modelList, err := FetchFromModelsDev(s.httpClient) + if err != nil { + Warn("Failed to fetch from models.dev, using default models", "error", err) + + // Ultimate fallback to hardcoded models + modelList = &transform.ModelList{ + Object: "list", + Data: GetDefault(), + } + } + + // Cache the results + cachedModels = modelList + modelsLoaded = true + + Info("Loaded and cached models", "count", len(modelList.Data)) + return modelList + }) + + modelList := result.(*transform.ModelList) + // 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(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 new file mode 100644 index 0000000..d099745 --- /dev/null +++ b/internal/models_test.go @@ -0,0 +1,548 @@ +package internal_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/privapps/github-copilot-svcs/internal" + "github.com/privapps/github-copilot-svcs/pkg/transform" +) + +// MockCoalescingCache implements CoalescingCacheInterface for testing +type MockCoalescingCache struct { + requests map[string]func() interface{} +} + +func NewMockCoalescingCache() *MockCoalescingCache { + return &MockCoalescingCache{ + requests: make(map[string]func() interface{}), + } +} + +func (m *MockCoalescingCache) GetRequestKey(method, path string, _ interface{}) string { + return method + ":" + path +} + +func (m *MockCoalescingCache) CoalesceRequest(_ string, fn func() interface{}) interface{} { + // For testing, just execute the function immediately + return fn() +} + +// Test helpers +func createTestModelsService() *internal.ModelsService { + cache := NewMockCoalescingCache() + httpClient := &http.Client{Timeout: 30 * time.Second} + return internal.NewModelsService(cache, httpClient) +} + +func TestNewModelsService(t *testing.T) { + cache := NewMockCoalescingCache() + httpClient := &http.Client{Timeout: 30 * time.Second} + + service := internal.NewModelsService(cache, httpClient) + + if service == nil { + t.Fatal("Expected models service to be created") + } + + // Test that the service has a handler + handler := service.Handler() + if handler == nil { + t.Error("Expected handler to be created") + } +} + +func TestGetDefault(t *testing.T) { + models := internal.GetDefault() + + if len(models) == 0 { + t.Error("Expected default models to be returned") + } + + // Verify structure of default models + 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 + + // Verify model structure + if model.Object != "model" { + t.Errorf("Expected model object to be 'model', got '%s'", model.Object) + } + 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, expected := range expectedModels { + model, exists := modelMap[expectedID] + if !exists { + t.Errorf("Expected model '%s' not found in default models", expectedID) + 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) + } + } +} + +func TestContainsAny(t *testing.T) { + tests := []struct { + name string + text string + substrings []string + expected bool + }{ + { + name: "matches case insensitive", + text: "GPT-4 Model", + substrings: []string{"gpt", "claude"}, + expected: true, + }, + { + name: "matches multiple options", + text: "Claude Sonnet", + substrings: []string{"gpt", "claude", "gemini"}, + expected: true, + }, + { + name: "no match", + text: "Random Model", + substrings: []string{"gpt", "claude", "gemini"}, + expected: false, + }, + { + name: "empty substrings", + text: "Any Text", + substrings: []string{}, + expected: false, + }, + { + name: "partial match", + text: "openai-gpt", + substrings: []string{"gpt"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: containsAny is not exported, so we test it indirectly through the models + // that use it in FetchFromModelsDev. This is a limitation of testing unexported functions. + + // For this test, we'll create a scenario and test the expected behavior + if tt.expected && !strings.Contains(strings.ToLower(tt.text), strings.ToLower(tt.substrings[0])) { + // This is just a basic check since we can't test the actual function + t.Skip("Cannot directly test unexported containsAny function") + } + }) + } +} + +func TestFetchFromModelsDev(t *testing.T) { + t.Run("successful fetch", func(t *testing.T) { + // Create a mock server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api.json" { + t.Errorf("Expected path '/api.json', got '%s'", r.URL.Path) + } + + response := map[string]interface{}{ + "github-copilot": map[string]interface{}{ + "id": "github-copilot", + "models": map[string]interface{}{ + "gpt-4": map[string]interface{}{ + "id": "gpt-4", + "name": "GPT-4", + "release_date": "2023-03-14", + "owned_by": "openai", + }, + "claude-3": map[string]interface{}{ + "id": "claude-3", + "name": "Claude 3", + "release_date": "2024-03-04", + "owned_by": "anthropic", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Fatalf("unexpected encode error: %v", err) + } + })) + defer testServer.Close() + + // Override the URL by creating a custom client and using the test server + httpClient := &http.Client{Timeout: 30 * time.Second} + + // We can't easily test the actual function since it has a hardcoded URL + // But we can test that it handles the response format correctly + resp, err := httpClient.Get(testServer.URL + "/api.json") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + var response map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Verify the structure we expect + if _, exists := response["github-copilot"]; !exists { + t.Error("Expected 'github-copilot' provider in response") + } + }) + + t.Run("handles network error", func(t *testing.T) { + httpClient := &http.Client{Timeout: 1 * time.Millisecond} // Very short timeout + + // This will likely fail due to the short timeout, which is what we want to test + _, err := internal.FetchFromModelsDev(httpClient) + if err == nil { + t.Log("Note: Network request unexpectedly succeeded, may be due to local caching") + } + // We don't fail the test since network conditions can vary + }) + + t.Run("handles non-200 status", func(t *testing.T) { + // Create a mock server that returns 404 + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer testServer.Close() + + // We can't easily override the URL in FetchFromModelsDev + // So this test just verifies the server behavior + resp, err := http.Get(testServer.URL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + t.Error("Expected non-200 status code") + } + }) +} + +func TestModelsServiceHandler_ReturnsModelsSuccessfully(t *testing.T) { + service := createTestModelsService() + handler := service.Handler() + + req := httptest.NewRequest("GET", "/v1/models", http.NoBody) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) + } + + // Parse the response + var modelList transform.ModelList + if err := json.NewDecoder(w.Body).Decode(&modelList); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if modelList.Object != "list" { + t.Errorf("Expected object to be 'list', got '%s'", modelList.Object) + } + + if len(modelList.Data) == 0 { + t.Error("Expected at least one model in the list") + } + + // Verify model structure + for i, model := range modelList.Data { + if model.ID == "" { + t.Errorf("Model %d: Expected non-empty ID", i) + } + if model.Object != "model" { + t.Errorf("Model %d: Expected object to be 'model', got '%s'", i, model.Object) + } + if model.Created == 0 { + t.Errorf("Model %d: Expected non-zero created timestamp", i) + } + if model.OwnedBy == "" { + t.Errorf("Model %d: Expected non-empty OwnedBy", i) + } + if model.APIType == "" { + t.Errorf("Model %d: Expected non-empty APIType", i) + } + } +} + +func TestModelsServiceHandler_HandlesConcurrentRequests(t *testing.T) { + service := createTestModelsService() + handler := service.Handler() + + // Make multiple concurrent requests + responses := make(chan *httptest.ResponseRecorder, 5) + for i := 0; i < 5; i++ { + go func() { + req := httptest.NewRequest("GET", "/v1/models", http.NoBody) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + responses <- w + }() + } + + // Collect all responses + for i := 0; i < 5; i++ { + w := <-responses + if w.Code != http.StatusOK { + t.Errorf("Request %d: Expected status 200, got %d", i, w.Code) + } + + var modelList transform.ModelList + if err := json.NewDecoder(w.Body).Decode(&modelList); err != nil { + t.Errorf("Request %d: Failed to decode response: %v", i, err) + continue + } + + if len(modelList.Data) == 0 { + t.Errorf("Request %d: Expected at least one model", i) + } + } +} + +func TestModelsServiceHandler_CachesModelsBetweenRequests(t *testing.T) { + service := createTestModelsService() + handler := service.Handler() + + // Make first request + req1 := httptest.NewRequest("GET", "/v1/models", http.NoBody) + w1 := httptest.NewRecorder() + handler.ServeHTTP(w1, req1) + + var modelList1 transform.ModelList + if err := json.NewDecoder(w1.Body).Decode(&modelList1); err != nil { + t.Fatalf("Failed to decode first response: %v", err) + } + + // Make second request + req2 := httptest.NewRequest("GET", "/v1/models", http.NoBody) + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, req2) + + var modelList2 transform.ModelList + if err := json.NewDecoder(w2.Body).Decode(&modelList2); err != nil { + t.Fatalf("Failed to decode second response: %v", err) + } + + // Results should be the same (cached) + if !reflect.DeepEqual(modelList1.Data, modelList2.Data) { + t.Error("Expected cached models to be identical between requests") + } +} + +func TestModelsServiceHandler_SupportsDifferentHTTPMethods(t *testing.T) { + service := createTestModelsService() + handler := service.Handler() + + methods := []string{"GET", "POST", "PUT", "DELETE"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/v1/models", http.NoBody) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // All methods should return the models (the handler doesn't check method) + if w.Code != http.StatusOK { + t.Errorf("Method %s: Expected status 200, got %d", method, w.Code) + } + }) + } +} + +func TestModelsDevResponseStructure(t *testing.T) { + // Test the ModelsDevResponse structure parsing + jsonData := `{ + "github-copilot": { + "id": "github-copilot", + "models": { + "gpt-4": { + "id": "gpt-4", + "name": "GPT-4", + "release_date": "2023-03-14", + "owned_by": "openai" + }, + "claude-3": { + "id": "claude-3", + "name": "Claude 3", + "release_date": "2024-03-04" + } + } + } + }` + + var response internal.ModelsDevResponse + if err := json.Unmarshal([]byte(jsonData), &response); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Verify structure + provider, exists := response["github-copilot"] + if !exists { + t.Fatal("Expected 'github-copilot' provider") + } + + if provider.ID != "github-copilot" { + t.Errorf("Expected provider ID 'github-copilot', got '%s'", provider.ID) + } + + if len(provider.Models) != 2 { + t.Errorf("Expected 2 models, got %d", len(provider.Models)) + } + + // Check specific models + if _, gpt4Exists := provider.Models["gpt-4"]; !gpt4Exists { + t.Error("Expected 'gpt-4' model") + } + + claude3, exists := provider.Models["claude-3"] + if !exists { + t.Error("Expected 'claude-3' model") + } else if claude3.OwnedBy != "" { + t.Errorf("Expected claude-3 owned_by to be empty, got '%s'", claude3.OwnedBy) + } +} + +func TestModelOwnershipDetection(t *testing.T) { + // This test verifies the owner detection logic indirectly + // by checking the default models have correct ownership + models := internal.GetDefault() + + ownershipTests := map[string]string{ + "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 { + if expectedOwner, exists := ownershipTests[model.ID]; exists { + if model.OwnedBy != expectedOwner { + t.Errorf("Model '%s': Expected owner '%s', got '%s'", + model.ID, expectedOwner, model.OwnedBy) + } + } + } +} + +func TestModelTimestamps(t *testing.T) { + models := internal.GetDefault() + + now := time.Now().Unix() + tolerance := int64(5) // 5 seconds tolerance + + for _, model := range models { + if model.Created == 0 { + t.Errorf("Model '%s': Expected non-zero created timestamp", model.ID) + } + + // Check that timestamp is recent (within tolerance) + if model.Created < now-tolerance || model.Created > now+tolerance { + t.Errorf("Model '%s': Created timestamp %d seems wrong (current: %d)", + model.ID, model.Created, now) + } + } +} + +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 +} + +func (c *CountingCache) GetRequestKey(method, path string, _ interface{}) string { + return method + ":" + path +} + +func (c *CountingCache) CoalesceRequest(_ string, fn func() interface{}) interface{} { + c.executeCount++ + return fn() +} + +func TestCoalescingCacheIntegration(t *testing.T) { + // Test that the models service properly uses the coalescing cache + cache := &CountingCache{executeCount: 0} + + httpClient := &http.Client{Timeout: 30 * time.Second} + service := internal.NewModelsService(cache, httpClient) + handler := service.Handler() + + // Make multiple requests + for i := 0; i < 3; i++ { + req := httptest.NewRequest("GET", "/v1/models", http.NoBody) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Request %d: Expected status 200, got %d", i, w.Code) + } + } + + // Verify cache was used + if cache.executeCount != 3 { + t.Errorf("Expected cache CoalesceRequest to be called 3 times, got %d", cache.executeCount) + } +} diff --git a/internal/proxy.go b/internal/proxy.go new file mode 100644 index 0000000..e94a3d7 --- /dev/null +++ b/internal/proxy.go @@ -0,0 +1,604 @@ +// Package internal provides proxy service logic for github-copilot-svcs. +package internal + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +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 +} + +const ( + maxChatRetries = 3 + baseChatRetryDelay = 1 // seconds + + // Circuit breaker configuration + circuitBreakerFailureThreshold = 5 + + // Request configuration + maxRequestBodySize = 5 * 1024 * 1024 // 5MB + streamingBufferSize = 1024 + + // Status code ranges + statusCodeServerError = 500 + statusCodeTooManyRequests = 429 + statusCodeRequestTimeout = 408 +) + +const ( + // ProxyCBStateClosed indicates the circuit breaker is closed. + ProxyCBStateClosed = 0 + // ProxyCBStateOpen indicates the circuit breaker is open. + ProxyCBStateOpen = 1 + // ProxyCBStateHalfOpen indicates the circuit breaker is half-open. + ProxyCBStateHalfOpen = 2 +) + +// CircuitBreakerState represents the state of the circuit breaker +type CircuitBreakerState int + +const ( + // CircuitClosed allows all requests through + CircuitClosed CircuitBreakerState = iota + // CircuitOpen rejects all requests + CircuitOpen + // CircuitHalfOpen allows limited requests through + CircuitHalfOpen +) + +// CircuitBreaker implements circuit breaker pattern for upstream API calls +type CircuitBreaker struct { + failureCount int64 + lastFailureTime time.Time + state CircuitBreakerState + timeout time.Duration + mutex sync.RWMutex +} + +// CoalescingCache handles request coalescing for identical requests +type CoalescingCache struct { + requests map[string]chan interface{} + mutex sync.RWMutex +} + +// ProxyService provides proxy functionality +type ProxyService struct { + config *Config + httpClient *http.Client + authService *AuthService + workerPool WorkerPoolInterface + circuitBreaker *CircuitBreaker + bufferPool *sync.Pool +} + +// WorkerPoolInterface interface for background processing +type WorkerPoolInterface interface { + Submit(job func()) +} + +// responseWrapper tracks if headers have been sent +type responseWrapper struct { + http.ResponseWriter + headersSent bool +} + +// NewCoalescingCache creates a new coalescing cache +func NewCoalescingCache() *CoalescingCache { + return &CoalescingCache{ + requests: make(map[string]chan interface{}), + } +} + +// GetRequestKey generates a cache key for request coalescing +func (cc *CoalescingCache) GetRequestKey(method, url string, body interface{}) string { + h := sha256.New() + h.Write([]byte(method)) + h.Write([]byte(url)) + if body != nil { + if bodyBytes, ok := body.([]byte); ok { + h.Write(bodyBytes) + } + } + return hex.EncodeToString(h.Sum(nil)) +} + +// CoalesceRequest executes a function only once for identical concurrent requests +func (cc *CoalescingCache) CoalesceRequest(key string, fn func() interface{}) interface{} { + cc.mutex.Lock() + + // Check if request is already in progress + if ch, exists := cc.requests[key]; exists { + cc.mutex.Unlock() + // Wait for the existing request to complete + return <-ch + } + + // Create new channel for this request + ch := make(chan interface{}, 1) + cc.requests[key] = ch + cc.mutex.Unlock() + + // Execute the request + result := fn() + + // Broadcast result to all waiting goroutines + ch <- result + close(ch) + + // Clean up + cc.mutex.Lock() + delete(cc.requests, key) + cc.mutex.Unlock() + + return result +} + +// NewProxyService creates a new proxy service +func NewProxyService(cfg *Config, httpClient *http.Client, authService *AuthService, workerPool WorkerPoolInterface) *ProxyService { + circuitBreaker := &CircuitBreaker{ + state: CircuitClosed, + timeout: time.Duration(cfg.Timeouts.CircuitBreaker) * time.Second, + } + + bufferPool := &sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } + + return &ProxyService{ + config: cfg, + httpClient: httpClient, + authService: authService, + workerPool: workerPool, + circuitBreaker: circuitBreaker, + bufferPool: bufferPool, + } +} + +// Handler returns an HTTP handler for the proxy endpoint +func (s *ProxyService) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Create context with extended timeout for long-lived streaming responses + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.config.Timeouts.ProxyContext)*time.Second) + defer cancel() + + // Check circuit breaker + if !s.circuitBreaker.canExecute() { + Warn("Circuit breaker is open, rejecting request") + http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable) + return + } + + // Limit request body size + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + + // Use a response wrapper to track if headers have been sent + respWrapper := &responseWrapper{ResponseWriter: w, headersSent: false} + + // Create a done channel to track completion + done := make(chan error, 1) + + // Submit request to worker pool + s.workerPool.Submit(func() { + defer func() { + if recovery := recover(); recovery != nil { + Error("Worker panic recovered", "panic", recovery) + done <- NewProxyError("request_processing", "worker panic during request processing", fmt.Errorf("panic: %v", recovery)) + } + }() + + err := s.processProxyRequest(ctx, respWrapper, r) + done <- err + }) + + // Wait for worker to complete or context timeout + select { + case err := <-done: + if err != nil { + Error("Worker error", "error", err) + // Only write error if headers haven't been sent + if !respWrapper.headersSent { + switch { + case strings.Contains(err.Error(), "authentication error"): + http.Error(w, err.Error(), http.StatusUnauthorized) + case strings.Contains(err.Error(), "token validation failed"): + http.Error(w, err.Error(), http.StatusUnauthorized) + case strings.Contains(err.Error(), "bad request"): + http.Error(w, err.Error(), http.StatusBadRequest) + case strings.Contains(err.Error(), "method not allowed"): + http.Error(w, err.Error(), http.StatusMethodNotAllowed) + default: + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + } + case <-ctx.Done(): + Warn("Request timeout in worker pool") + // Only write timeout error if headers haven't been sent + if !respWrapper.headersSent { + http.Error(w, "Request timeout", http.StatusRequestTimeout) + } + } + } +} + +func (rw *responseWrapper) WriteHeader(statusCode int) { + if !rw.headersSent { + rw.headersSent = true + rw.ResponseWriter.WriteHeader(statusCode) + } +} + +func (rw *responseWrapper) Write(data []byte) (int, error) { + if !rw.headersSent { + rw.headersSent = true + } + return rw.ResponseWriter.Write(data) +} + +func (cb *CircuitBreaker) canExecute() bool { + cb.mutex.RLock() + defer cb.mutex.RUnlock() + + // No metrics to update for circuit breaker state changes + + if cb.state == CircuitClosed { + return true + } + + if cb.state == CircuitOpen { + if time.Since(cb.lastFailureTime) > cb.timeout { + cb.mutex.RUnlock() + cb.mutex.Lock() + cb.state = CircuitHalfOpen + cb.mutex.Unlock() + cb.mutex.RLock() + return true + } + return false + } + + // CircuitHalfOpen + return true +} + +func (cb *CircuitBreaker) onSuccess() { + cb.mutex.Lock() + defer cb.mutex.Unlock() + + cb.failureCount = 0 + cb.state = CircuitClosed +} + +func (cb *CircuitBreaker) onFailure() { + cb.mutex.Lock() + defer cb.mutex.Unlock() + + cb.failureCount++ + cb.lastFailureTime = time.Now() + + if cb.failureCount >= circuitBreakerFailureThreshold { + cb.state = CircuitOpen + } +} + +func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + Debug("Starting proxy request", "method", r.Method, "path", r.URL.Path) + + // Validate method + if r.Method != http.MethodPost { + return fmt.Errorf("method not allowed: %s", r.Method) + } + + // Read the request body + body, err := io.ReadAll(r.Body) + if err != nil { + Error("Error reading request body", "error", err) + // Check for "http: request body too large" error and return 413 + if strings.Contains(err.Error(), "http: request body too large") { + return fmt.Errorf("payload too large: %w", err) + } + return fmt.Errorf("bad request: failed to read request body: %w", err) + } + defer func() { + if err := r.Body.Close(); err != nil { + Warn("Error closing request body", "error", err) + } + }() + + // Basic body validation (for demonstration: consider empty body an error) + if len(body) == 0 { + return fmt.Errorf("bad request: empty request body") + } + + 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) + return NewAuthError("token validation failed", tokenErr) + } + + // Create new request to GitHub Copilot + 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)) + if err != nil { + Error("Error creating request", "error", err) + return NewProxyError("create_request", "failed to create proxy request", err) + } + + // 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("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) + req.Header.Set("Copilot-Integration-Id", s.config.Headers.CopilotIntegrationID) + req.Header.Set("Openai-Intent", s.config.Headers.OpenaiIntent) + req.Header.Set("X-Initiator", s.config.Headers.XInitiator) + + resp, err := s.makeRequestWithRetry(req, body) + if err != nil { + s.circuitBreaker.onFailure() + Error("Error making request after retries", "error", err) + 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) + } + }() + + // Update circuit breaker based on response + if resp.StatusCode < statusCodeServerError { + s.circuitBreaker.onSuccess() + } else { + s.circuitBreaker.onFailure() + } + + Debug("Received response", "status", resp.StatusCode, "content_type", resp.Header.Get("Content-Type")) + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Add configurable CORS headers + if len(s.config.CORS.AllowedOrigins) > 0 { + w.Header().Set("Access-Control-Allow-Origin", strings.Join(s.config.CORS.AllowedOrigins, ", ")) + } + if len(s.config.CORS.AllowedHeaders) > 0 { + w.Header().Set("Access-Control-Allow-Headers", strings.Join(s.config.CORS.AllowedHeaders, ", ")) + } + + // Copy status code + w.WriteHeader(resp.StatusCode) + + // Handle streaming vs regular responses + if resp.Header.Get("Content-Type") == "text/event-stream" { + return s.handleStreamingResponse(w, resp) + } + return s.handleRegularResponse(w, resp) +} + +func (s *ProxyService) handleStreamingResponse(w http.ResponseWriter, resp *http.Response) error { + Debug("Starting streaming response copy") + + if flusher, ok := w.(http.Flusher); ok { + // Copy in chunks and flush periodically for better streaming + buf := make([]byte, streamingBufferSize) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + _, writeErr := w.Write(buf[:n]) + if writeErr != nil { + Error("Error writing streaming chunk", "error", writeErr) + return writeErr + } + flusher.Flush() + } + if readErr == io.EOF { + Debug("Streaming response completed successfully") + break + } + if readErr != nil { + Error("Error reading streaming response", "error", readErr) + return readErr + } + } + } else { + // Fallback to direct copy if no flusher available + _, err := io.Copy(w, resp.Body) + if err != nil { + Error("Error copying streaming response", "error", err) + return err + } + } + return nil +} + +func (s *ProxyService) handleRegularResponse(w http.ResponseWriter, resp *http.Response) error { + Debug("Starting regular response copy") + + // Use buffer pool for regular responses + buf := s.bufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer s.bufferPool.Put(buf) + + _, err := io.CopyBuffer(w, resp.Body, buf.Bytes()[:0]) + if err != nil { + Error("Error copying response", "error", err) + return err + } + + Debug("Regular response completed successfully") + return nil +} + +func (s *ProxyService) makeRequestWithRetry(req *http.Request, body []byte) (*http.Response, error) { + var lastResp *http.Response + var lastErr error + + for attempt := 1; attempt <= maxChatRetries; attempt++ { + // Create a new request for each attempt with the original context + retryReq, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + // Copy all headers + for key, values := range req.Header { + for _, value := range values { + retryReq.Header.Add(key, value) + } + } + + Debug("Making request attempt", "attempt", attempt, "max_attempts", maxChatRetries) + + resp, err := s.httpClient.Do(retryReq) + if err != nil { + lastErr = err + if attempt == maxChatRetries { + Error("Request failed after max attempts", "attempts", maxChatRetries, "error", err) + return nil, err + } + + // Context-aware waiting instead of blocking sleep + waitTime := time.Duration(baseChatRetryDelay*attempt*attempt) * time.Second + Warn("Request failed, retrying", "attempt", attempt, "wait_time", waitTime, "error", err) + + timer := time.NewTimer(waitTime) + select { + case <-timer.C: + // Continue with retry + case <-req.Context().Done(): + timer.Stop() + return nil, req.Context().Err() + } + continue + } + + lastResp = resp + + // Check if we should retry based on status code + if !s.isRetriableError(resp.StatusCode, nil) { + Debug("Request successful", "attempt", attempt, "status", resp.StatusCode) + return resp, nil + } + + // Close the response body before retrying + if closeErr := resp.Body.Close(); closeErr != nil { + Warn("Failed to close response body during retry", "error", closeErr) + } + + if attempt == maxChatRetries { + Warn("Request failed after max attempts", "attempts", maxChatRetries, "status", resp.StatusCode) + return resp, nil // Return the last response even if it failed + } + + // Context-aware waiting for status code retries + waitTime := time.Duration(baseChatRetryDelay*attempt*attempt) * time.Second + Warn("Request failed, retrying", "status", resp.StatusCode, "attempt", attempt, "wait_time", waitTime) + + timer := time.NewTimer(waitTime) + select { + case <-timer.C: + // Continue with retry + case <-req.Context().Done(): + timer.Stop() + return nil, req.Context().Err() + } + } + + return lastResp, lastErr +} + +func (s *ProxyService) isRetriableError(statusCode int, err error) bool { + if err != nil { + return true // Network errors are retriable + } + + // Retry on server errors and rate limiting + return statusCode >= statusCodeServerError || statusCode == statusCodeTooManyRequests || statusCode == statusCodeRequestTimeout +} diff --git a/internal/server.go b/internal/server.go new file mode 100644 index 0000000..0610bf0 --- /dev/null +++ b/internal/server.go @@ -0,0 +1,223 @@ +package internal + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "runtime" + "sync" + "syscall" + "time" +) + +// Constants for timeout values +const ( + shutdownTimeout = 10 * time.Second + + // HTTP client configuration + maxIdleConns = 100 + maxIdleConnsPerHost = 20 + workerMultiplier = 2 +) + +// Server represents the HTTP server and its dependencies +type Server struct { + config *Config + httpServer *http.Server + httpClient *http.Client + workerPool *WorkerPool +} + +// WorkerPool handles background processing +type WorkerPool struct { + workers int + jobQueue chan func() + quit chan bool + wg sync.WaitGroup +} + +// NewWorkerPool creates a new worker pool +func NewWorkerPool(workers int) *WorkerPool { + if workers <= 0 { + workers = runtime.NumCPU() + } + + wp := &WorkerPool{ + workers: workers, + jobQueue: make(chan func(), workers*workerMultiplier), // Buffer for burst traffic + quit: make(chan bool), + } + + wp.start() + return wp +} + +func (wp *WorkerPool) start() { + for i := 0; i < wp.workers; i++ { + wp.wg.Add(1) + go func() { + defer wp.wg.Done() + for { + select { + case job := <-wp.jobQueue: + job() + case <-wp.quit: + return + } + } + }() + } +} + +// Submit adds a job to the worker pool +func (wp *WorkerPool) Submit(job func()) { + wp.jobQueue <- job +} + +// Stop gracefully stops the worker pool +func (wp *WorkerPool) Stop() { + close(wp.quit) + wp.wg.Wait() +} + +// CreateHTTPClient creates a configured HTTP client +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, + DialContext: (&net.Dialer{ + Timeout: time.Duration(cfg.Timeouts.DialTimeout) * time.Second, + KeepAlive: time.Duration(cfg.Timeouts.KeepAlive) * time.Second, + }).DialContext, + TLSHandshakeTimeout: time.Duration(cfg.Timeouts.TLSHandshake) * time.Second, + }, + } +} + +// NewServer creates a new server instance +func NewServer(cfg *Config, httpClient *http.Client) *Server { + workerPool := NewWorkerPool(runtime.NumCPU() * workerMultiplier) + + // Create auth service + authService := NewAuthService(httpClient) + + // Create coalescing cache for models + coalescingCache := NewCoalescingCache() + modelsService := NewModelsService(coalescingCache, httpClient) + + // Create proxy service + proxyService := NewProxyService(cfg, httpClient, authService, workerPool) + + // Create health checker + healthChecker := NewHealthChecker(httpClient, "dev") // TODO: get version from build + + 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 + mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP) + mux.HandleFunc("/debug/pprof/cmdline", http.DefaultServeMux.ServeHTTP) + mux.HandleFunc("/debug/pprof/profile", http.DefaultServeMux.ServeHTTP) + mux.HandleFunc("/debug/pprof/symbol", http.DefaultServeMux.ServeHTTP) + mux.HandleFunc("/debug/pprof/trace", http.DefaultServeMux.ServeHTTP) + + port := cfg.Port + if port == 0 { + port = 8081 // default port + } + + // Build middleware chain + var handler http.Handler = mux + + // Apply middleware in reverse order (last applied = first executed) + handler = SecurityHeadersMiddleware(handler) + handler = CORSMiddleware(cfg)(handler) + handler = LoggingMiddleware(handler) + handler = RecoveryMiddleware(handler) + // Note: TimeoutMiddleware could be added here if needed per-request timeouts + // handler = TimeoutMiddleware(time.Duration(cfg.Timeouts.ProxyContext) * time.Second)(handler) + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: handler, + ReadTimeout: time.Duration(cfg.Timeouts.ServerRead) * time.Second, + WriteTimeout: time.Duration(cfg.Timeouts.ServerWrite) * time.Second, + IdleTimeout: time.Duration(cfg.Timeouts.ServerIdle) * time.Second, + } + + return &Server{ + config: cfg, + httpServer: httpServer, + httpClient: httpClient, + workerPool: workerPool, + } +} + +// Start starts the HTTP server with graceful shutdown +func (s *Server) Start() error { + s.setupGracefulShutdown() + + port := s.config.Port + if port == 0 { + port = 8081 + } + + fmt.Printf("Starting GitHub Copilot proxy server on port %d...\n", port) + 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 { + return fmt.Errorf("server failed: %v", err) + } + + return nil +} + +// Stop gracefully stops the server +func (s *Server) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + fmt.Println("Stopping worker pool...") + s.workerPool.Stop() + fmt.Println("Worker pool stopped.") + + fmt.Println("Shutting down HTTP server...") + err := s.httpServer.Shutdown(ctx) + if err != nil { + fmt.Printf("Error during HTTP server shutdown: %v\n", err) + return err + } + fmt.Println("HTTP server shutdown complete.") + + return nil +} + +func (s *Server) setupGracefulShutdown() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + fmt.Println("\nGracefully shutting down...") + + if err := s.Stop(); err != nil { + Error("Server shutdown error", "error", err) + } + }() +} + +// healthHandler is now replaced by the comprehensive HealthChecker diff --git a/internal/server_test.go b/internal/server_test.go new file mode 100644 index 0000000..c773373 --- /dev/null +++ b/internal/server_test.go @@ -0,0 +1,489 @@ +package internal_test + +import ( + "net/http" + "net/http/httptest" + "runtime" + "sync" + "testing" + "time" + + "github.com/privapps/github-copilot-svcs/internal" +) + +// Test helpers +func createServerTestConfig() *internal.Config { + cfg := &internal.Config{ + Port: 0, // Use 0 to let the system assign a port + } + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + return cfg +} + +func TestNewWorkerPool(t *testing.T) { + t.Run("creates worker pool with specified workers", func(t *testing.T) { + workers := 4 + wp := internal.NewWorkerPool(workers) + + if wp == nil { + t.Fatal("Expected worker pool to be created") + } + + // Clean up + wp.Stop() + }) + + t.Run("uses default workers when invalid count provided", func(t *testing.T) { + wp := internal.NewWorkerPool(0) + + if wp == nil { + t.Fatal("Expected worker pool to be created with default workers") + } + + // Clean up + wp.Stop() + }) + + t.Run("uses default workers for negative count", func(t *testing.T) { + wp := internal.NewWorkerPool(-1) + + if wp == nil { + t.Fatal("Expected worker pool to be created with default workers") + } + + // Clean up + wp.Stop() + }) +} + +func TestWorkerPoolJobExecution(t *testing.T) { + t.Run("executes submitted jobs", func(t *testing.T) { + wp := internal.NewWorkerPool(2) + defer wp.Stop() + + executed := false + var mutex sync.Mutex + + wp.Submit(func() { + mutex.Lock() + executed = true + mutex.Unlock() + }) + + // Wait a bit for the job to execute + time.Sleep(100 * time.Millisecond) + + mutex.Lock() + if !executed { + t.Error("Expected job to be executed") + } + mutex.Unlock() + }) + + t.Run("executes multiple jobs concurrently", func(t *testing.T) { + wp := internal.NewWorkerPool(3) + defer wp.Stop() + + numJobs := 10 + executed := make([]bool, numJobs) + var mutex sync.Mutex + var wg sync.WaitGroup + + for i := 0; i < numJobs; i++ { + wg.Add(1) + index := i + wp.Submit(func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) // Simulate work + mutex.Lock() + executed[index] = true + mutex.Unlock() + }) + } + + // Wait for all jobs to complete + wg.Wait() + + // Check all jobs were executed + for i, wasExecuted := range executed { + if !wasExecuted { + t.Errorf("Job %d was not executed", i) + } + } + }) + + // Note: The current worker pool implementation doesn't have panic recovery + // so we can't test panic handling. This would need to be added to the worker pool + // if panic recovery is required. +} + +func TestWorkerPoolStop(t *testing.T) { + t.Run("stops gracefully", func(t *testing.T) { + wp := internal.NewWorkerPool(2) + + // Submit some jobs + for i := 0; i < 5; i++ { + wp.Submit(func() { + time.Sleep(10 * time.Millisecond) + }) + } + + // Stop should complete without hanging + done := make(chan bool, 1) + go func() { + wp.Stop() + done <- true + }() + + select { + case <-done: + // Success + case <-time.After(5 * time.Second): + t.Error("Worker pool stop timed out") + } + }) + + t.Run("stop completes successfully", func(t *testing.T) { + wp := internal.NewWorkerPool(1) + + // Submit a job to ensure workers are running + wp.Submit(func() { + time.Sleep(10 * time.Millisecond) + }) + + // Stop should complete without hanging + done := make(chan bool, 1) + go func() { + wp.Stop() + done <- true + }() + + select { + case <-done: + // Success + case <-time.After(2 * time.Second): + t.Error("Worker pool stop timed out") + } + }) +} + +func TestCreateHTTPClient(t *testing.T) { + t.Run("creates client with correct configuration", func(t *testing.T) { + cfg := createServerTestConfig() + client := internal.CreateHTTPClient(cfg) + + if client == nil { + t.Fatal("Expected HTTP client to be created") + } + + if client.Timeout != time.Duration(cfg.Timeouts.HTTPClient)*time.Second { + t.Errorf("Expected timeout %v, got %v", + time.Duration(cfg.Timeouts.HTTPClient)*time.Second, + client.Timeout) + } + + // Check transport configuration + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("Expected transport to be *http.Transport") + } + + if transport.MaxIdleConns != 100 { + t.Errorf("Expected MaxIdleConns 100, got %d", transport.MaxIdleConns) + } + + if transport.MaxIdleConnsPerHost != 20 { + t.Errorf("Expected MaxIdleConnsPerHost 20, got %d", transport.MaxIdleConnsPerHost) + } + }) + + t.Run("creates functional client", func(t *testing.T) { + cfg := createServerTestConfig() + client := internal.CreateHTTPClient(cfg) + + // Create a test server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("test response")); err != nil { + t.Errorf("unexpected write error: %v", err) + } + })) + defer testServer.Close() + + // Make a request + resp, err := client.Get(testServer.URL) + if err != nil { + t.Fatalf("Expected successful request, got error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + }) +} + +func TestNewServer(t *testing.T) { + t.Run("creates server with correct configuration", func(t *testing.T) { + cfg := createServerTestConfig() + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Fatal("Expected server to be created") + } + + // Note: We can't easily test internal fields since they're not exported + // But we can test that the server was created successfully + }) + + t.Run("uses default port when not specified", func(t *testing.T) { + cfg := createServerTestConfig() + cfg.Port = 0 // Explicitly set to 0 + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Fatal("Expected server to be created") + } + }) + + t.Run("creates server with custom port", func(t *testing.T) { + cfg := createServerTestConfig() + cfg.Port = 9999 + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Fatal("Expected server to be created") + } + }) +} + +func TestServerStartStop(t *testing.T) { + t.Run("server starts and stops gracefully", func(t *testing.T) { + cfg := createServerTestConfig() + cfg.Port = 0 // Let system assign port + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + // Start server in background + errCh := make(chan error, 1) + go func() { + errCh <- server.Start() + }() + + // Give server time to start + time.Sleep(100 * time.Millisecond) + + // Stop server + stopErr := server.Stop() + if stopErr != nil { + t.Errorf("Expected clean stop, got error: %v", stopErr) + } + + // Wait for start to complete + select { + case startErr := <-errCh: + if startErr != nil && startErr != http.ErrServerClosed { + t.Errorf("Expected clean start/stop, got error: %v", startErr) + } + case <-time.After(2 * time.Second): + t.Error("Server start did not complete within timeout") + } + }) + + t.Run("server stops gracefully", func(t *testing.T) { + cfg := createServerTestConfig() + cfg.Port = 0 + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + // Start server in background + go func() { + if err := server.Start(); err != nil { + t.Errorf("server.Start() error: %v", err) + } + }() + + time.Sleep(100 * time.Millisecond) + + // Stop server + err := server.Stop() + if err != nil { + t.Errorf("Stop error: %v", err) + } + }) +} + +func TestServerRoutes(t *testing.T) { + t.Run("server has correct routes", func(t *testing.T) { + cfg := createServerTestConfig() + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + // We can't easily test routes directly since the server struct doesn't expose them + // But we can test that the server was created, which implies routes are set up + if server == nil { + t.Fatal("Expected server to be created with routes") + } + }) +} + +func TestServerConcurrency(t *testing.T) { + t.Run("handles concurrent operations", func(t *testing.T) { + numGoroutines := 10 + var wg sync.WaitGroup + + // Create multiple servers concurrently + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + cfg := createServerTestConfig() + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Error("Expected server to be created in concurrent goroutine") + } + }() + } + + wg.Wait() + }) +} + +func TestWorkerPoolConfiguration(t *testing.T) { + t.Run("worker pool uses CPU multiplier", func(t *testing.T) { + // This test verifies that NewWorkerPool is called with runtime.NumCPU() * 2 + // We can't directly test the worker count, but we can verify the pool works + cfg := createServerTestConfig() + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Fatal("Expected server to be created with worker pool") + } + + // The worker pool should be functioning (indirectly tested through server creation) + }) +} + +func TestHTTPClientTimeout(t *testing.T) { + t.Run("HTTP client respects timeout configuration", func(t *testing.T) { + cfg := createServerTestConfig() + cfg.Timeouts.HTTPClient = 1 // 1 second timeout + + client := internal.CreateHTTPClient(cfg) + + // Create a test server that delays response + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(2 * time.Second) // Longer than client timeout + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + // Make request that should timeout + resp, err := client.Get(testServer.URL) + if resp != nil { + defer resp.Body.Close() + } + if err == nil { + t.Error("Expected timeout error, but request succeeded") + } + + // Check if it's a timeout error (error message may vary by Go version) + if err != nil && !isTimeoutError(err) { + t.Errorf("Expected timeout error, got: %v", err) + } + }) +} + +// Helper function to check if error is a timeout error +func isTimeoutError(err error) bool { + if err == nil { + return false + } + // Check common timeout error patterns + errStr := err.Error() + return contains(errStr, "timeout") || + contains(errStr, "deadline exceeded") || + contains(errStr, "context deadline exceeded") +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || substr == "" || + (len(s) > len(substr) && contains(s[1:], substr)) || + (len(s) >= len(substr) && s[:len(substr)] == substr)) +} + +func TestServerMemoryManagement(t *testing.T) { + t.Run("server creation doesn't leak memory", func(t *testing.T) { + // Simple test to ensure server creation/destruction works properly + for i := 0; i < 100; i++ { + cfg := createServerTestConfig() + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Fatalf("Server creation failed at iteration %d", i) + } + + // Immediately "destroy" by letting it go out of scope + } + + // Force garbage collection + runtime.GC() + }) +} + +func TestServerConfigurationDefaults(t *testing.T) { + t.Run("server handles missing configuration gracefully", func(t *testing.T) { + cfg := &internal.Config{} // Minimal config + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + httpClient := internal.CreateHTTPClient(cfg) + server := internal.NewServer(cfg, httpClient) + + if server == nil { + t.Error("Expected server to be created with default configuration") + } + }) +} + +func TestWorkerPoolBuffer(t *testing.T) { + t.Run("worker pool handles burst traffic", func(t *testing.T) { + wp := internal.NewWorkerPool(2) + defer wp.Stop() + + // Submit more jobs than workers to test buffering + numJobs := 10 + executed := 0 + var mutex sync.Mutex + var wg sync.WaitGroup + + for i := 0; i < numJobs; i++ { + wg.Add(1) + wp.Submit(func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + mutex.Lock() + executed++ + mutex.Unlock() + }) + } + + wg.Wait() + + mutex.Lock() + if executed != numJobs { + t.Errorf("Expected %d jobs executed, got %d", numJobs, executed) + } + mutex.Unlock() + }) +} diff --git a/main.go b/main.go deleted file mode 100644 index 9834393..0000000 --- a/main.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/user" - "path/filepath" - "time" -) - -// version will be set by the build process -var version = "dev" - -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"` - - // Timeout configurations (in seconds) - Timeouts struct { - HTTPClient int `json:"http_client"` // Default: 300s for streaming responses - ServerRead int `json:"server_read"` // Default: 30s for request reading - ServerWrite int `json:"server_write"` // Default: 300s for streaming responses - ServerIdle int `json:"server_idle"` // Default: 120s for idle connections - ProxyContext int `json:"proxy_context"` // Default: 300s for proxy request context - CircuitBreaker int `json:"circuit_breaker"` // Default: 30s for circuit breaker recovery - KeepAlive int `json:"keep_alive"` // Default: 30s for connection keep-alive - TLSHandshake int `json:"tls_handshake"` // Default: 10s for TLS handshake - DialTimeout int `json:"dial_timeout"` // Default: 10s for connection dialing - IdleConnTimeout int `json:"idle_conn_timeout"` // Default: 90s for idle connection timeout - } `json:"timeouts"` -} - -const ( - configDirName = ".local/share/github-copilot-svcs" - configFileName = "config.json" -) - -func getConfigPath() (string, error) { - usr, err := user.Current() - if err != nil { - return "", err - } - dir := filepath.Join(usr.HomeDir, configDirName) - if err := os.MkdirAll(dir, 0700); err != nil { - return "", err - } - return filepath.Join(dir, configFileName), nil -} - -func getDefaultModels() []Model { - // Models based on actual models.dev GitHub Copilot, Claude, and Gemini entries (as of August 2025) - return []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"}, - } -} - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: github-copilot-svcs ") - fmt.Println("Commands:") - fmt.Println(" auth Authenticate with GitHub Copilot") - fmt.Println(" run Start the proxy server") - fmt.Println(" models List available models") - fmt.Println(" config Show current configuration") - fmt.Println(" status Show authentication and token status") - fmt.Println(" refresh Force refresh of Copilot token") - fmt.Println(" version Show version information") - return - } - - switch os.Args[1] { - case "auth": - if err := handleAuth(); err != nil { - fmt.Printf("Authentication failed: %v\n", err) - os.Exit(1) - } - case "run": - if err := handleRun(); err != nil { - fmt.Printf("Server failed: %v\n", err) - os.Exit(1) - } - case "models": - if err := handleModels(); err != nil { - fmt.Printf("Models command failed: %v\n", err) - os.Exit(1) - } - case "config": - if err := handleConfig(); err != nil { - fmt.Printf("Config command failed: %v\n", err) - os.Exit(1) - } - case "status": - if err := handleStatus(); err != nil { - fmt.Printf("Status command failed: %v\n", err) - os.Exit(1) - } - case "refresh": - if err := handleRefresh(); err != nil { - fmt.Printf("Refresh command failed: %v\n", err) - os.Exit(1) - } - case "version": - fmt.Printf("github-copilot-svcs version %s\n", version) - default: - fmt.Printf("Unknown command: %s\n", os.Args[1]) - os.Exit(1) - } -} diff --git a/models.go b/models.go deleted file mode 100644 index 6db7d22..0000000 --- a/models.go +++ /dev/null @@ -1,179 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "strings" - "sync" - "time" -) - -var ( - cachedModels *ModelList - modelsMutex sync.RWMutex - modelsLoaded bool -) - -// ModelsDevResponse represents the structure from models.dev API -type ModelsDevResponse map[string]struct { - ID string `json:"id"` - Models map[string]struct { - ID string `json:"id"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - OwnedBy string `json:"owned_by,omitempty"` - } `json:"models"` -} - -// fetchModelsFromCopilotAPI tries to get models directly from GitHub Copilot API -func fetchModelsFromCopilotAPI(token string) (*ModelList, error) { - req, err := http.NewRequest("GET", "https://api.githubcopilot.com/v1/models", nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", userAgent) - - resp, err := sharedHTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("copilot API returned status %d", resp.StatusCode) - } - - var modelList ModelList - if err := json.NewDecoder(resp.Body).Decode(&modelList); err != nil { - return nil, err - } - - return &modelList, nil -} - -// fetchModelsFromModelsDev fetches models from models.dev API as fallback -func fetchModelsFromModelsDev() (*ModelList, error) { - resp, err := http.Get("https://models.dev/api.json") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("models.dev API returned status %d", resp.StatusCode) - } - - var providers ModelsDevResponse - if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil { - return nil, err - } - - // Extract GitHub Copilot models - copilotProvider, exists := providers["github-copilot"] - if !exists { - return nil, fmt.Errorf("github-copilot provider not found in models.dev") - } - - var models []Model - for modelID, modelInfo := range copilotProvider.Models { - ownedBy := modelInfo.OwnedBy - if ownedBy == "" { - // Determine owner based on model name - if containsAny(modelInfo.Name, []string{"claude", "anthropic"}) { - ownedBy = "anthropic" - } else if containsAny(modelInfo.Name, []string{"gpt", "o1", "o3", "o4", "openai"}) { - ownedBy = "openai" - } else if containsAny(modelInfo.Name, []string{"gemini", "google"}) { - ownedBy = "google" - } else { - ownedBy = "github-copilot" - } - } - - models = append(models, Model{ - ID: modelID, - Object: "model", - Created: time.Now().Unix(), - OwnedBy: ownedBy, - }) - } - - return &ModelList{ - Object: "list", - Data: models, - }, nil -} - -// containsAny checks if text contains any of the substrings -func containsAny(text string, substrings []string) bool { - textLower := strings.ToLower(text) - for _, substr := range substrings { - if strings.Contains(textLower, strings.ToLower(substr)) { - return true - } - } - return false -} - -// getDefaultModels provides a fallback list of models (defined in main.go) - -func modelsHandler(cfg *Config) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Use request coalescing for identical concurrent requests - requestKey := modelsCoalescingCache.getRequestKey("GET", "/v1/models", nil) - - result := modelsCoalescingCache.CoalesceRequest(requestKey, func() interface{} { - // Check cache first - modelsMutex.RLock() - if modelsLoaded && cachedModels != nil { - modelsMutex.RUnlock() - return cachedModels - } - modelsMutex.RUnlock() - - // Load models if not cached - modelsMutex.Lock() - defer modelsMutex.Unlock() - - // Double-check in case another goroutine loaded while we waited - if modelsLoaded && cachedModels != nil { - return cachedModels - } - - log.Printf("Loading models for the first time...") - - // Try models.dev API first (don't hit GitHub Copilot for models list) - modelList, err := fetchModelsFromModelsDev() - if err != nil { - log.Printf("Failed to fetch from models.dev: %v, using default models", err) - - // Ultimate fallback to hardcoded models - modelList = &ModelList{ - Object: "list", - Data: getDefaultModels(), - } - } - - // Cache the results - cachedModels = modelList - modelsLoaded = true - - log.Printf("Loaded and cached %d models", len(modelList.Data)) - return modelList - }) - - modelList := result.(*ModelList) - log.Printf("Returning models (%d models)", len(modelList.Data)) - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(modelList); err != nil { - log.Printf("Error encoding models response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - } - } -} diff --git a/transform.go b/pkg/transform/transform.go similarity index 51% rename from transform.go rename to pkg/transform/transform.go index 0e2046c..ec77453 100644 --- a/transform.go +++ b/pkg/transform/transform.go @@ -1,6 +1,9 @@ -package main +// Package transform provides OpenAI-compatible request/response structures for github-copilot-svcs. +package transform -// OpenAI-compatible request/response structures +import "encoding/json" + +// ChatCompletionRequest ... type ChatCompletionRequest struct { Model string `json:"model"` Messages []ChatCompletionMessage `json:"messages"` @@ -9,11 +12,29 @@ type ChatCompletionRequest struct { Stream bool `json:"stream,omitempty"` } +// 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 ... type ChatCompletionResponse struct { ID string `json:"id"` Object string `json:"object"` @@ -23,26 +44,31 @@ type ChatCompletionResponse struct { Usage ChatCompletionUsage `json:"usage"` } +// ChatCompletionChoice ... type ChatCompletionChoice struct { Index int `json:"index"` Message ChatCompletionMessage `json:"message"` FinishReason string `json:"finish_reason"` } +// ChatCompletionUsage ... type ChatCompletionUsage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } +// ModelList ... type ModelList struct { Object string `json:"object"` Data []Model `json:"data"` } +// Model ... type Model struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` + APIType string `json:"api_type"` // "chat_completions" or "responses" } diff --git a/proxy.go b/proxy.go deleted file mode 100644 index 7a57f89..0000000 --- a/proxy.go +++ /dev/null @@ -1,547 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log" - "net" - "net/http" - "runtime" - "sync" - "time" -) - -const ( - copilotAPIBase = "https://api.githubcopilot.com" - - // Retry configuration for chat completions - maxChatRetries = 3 - baseChatRetryDelay = 1 // seconds - - // Circuit breaker configuration - timeout will be loaded from config - circuitBreakerFailureThreshold = 5 -) - -// Simple circuit breaker state -type CircuitBreakerState int - -const ( - CircuitClosed CircuitBreakerState = iota - CircuitOpen - CircuitHalfOpen -) - -// Circuit breaker for upstream API calls -type CircuitBreaker struct { - failureCount int64 - lastFailureTime time.Time - state CircuitBreakerState - timeout time.Duration - mutex sync.RWMutex -} - -var circuitBreaker = &CircuitBreaker{ - state: CircuitClosed, - timeout: 30 * time.Second, // Default, will be updated from config -} - -var tokenMu sync.Mutex - -// Circuit breaker methods -func (cb *CircuitBreaker) canExecute() bool { - cb.mutex.RLock() - defer cb.mutex.RUnlock() - - if cb.state == CircuitClosed { - return true - } - - if cb.state == CircuitOpen { - if time.Since(cb.lastFailureTime) > cb.timeout { - cb.mutex.RUnlock() - cb.mutex.Lock() - cb.state = CircuitHalfOpen - cb.mutex.Unlock() - cb.mutex.RLock() - return true - } - return false - } - - // CircuitHalfOpen - return true -} - -func (cb *CircuitBreaker) onSuccess() { - cb.mutex.Lock() - defer cb.mutex.Unlock() - - cb.failureCount = 0 - cb.state = CircuitClosed -} - -func (cb *CircuitBreaker) onFailure() { - cb.mutex.Lock() - defer cb.mutex.Unlock() - - cb.failureCount++ - cb.lastFailureTime = time.Now() - - if cb.failureCount >= circuitBreakerFailureThreshold { - cb.state = CircuitOpen - } -} - -var sharedHTTPClient *http.Client - -// initializeTimeouts initializes all timeout configurations from config -func initializeTimeouts(cfg *Config) { - // Update circuit breaker timeout - circuitBreaker.mutex.Lock() - circuitBreaker.timeout = time.Duration(cfg.Timeouts.CircuitBreaker) * time.Second - circuitBreaker.mutex.Unlock() - - // Initialize HTTP client with config timeouts - sharedHTTPClient = &http.Client{ - Timeout: time.Duration(cfg.Timeouts.HTTPClient) * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 20, - IdleConnTimeout: time.Duration(cfg.Timeouts.IdleConnTimeout) * time.Second, - DialContext: (&net.Dialer{ - Timeout: time.Duration(cfg.Timeouts.DialTimeout) * time.Second, - KeepAlive: time.Duration(cfg.Timeouts.KeepAlive) * time.Second, - }).DialContext, - TLSHandshakeTimeout: time.Duration(cfg.Timeouts.TLSHandshake) * time.Second, - }, - } -} - -// Buffer pool for request/response reuse -var bufferPool = sync.Pool{ - New: func() interface{} { - return new(bytes.Buffer) - }, -} - -// Worker pool for handling requests -type WorkerPool struct { - workers int - jobQueue chan func() - quit chan bool - wg sync.WaitGroup -} - -func NewWorkerPool(workers int) *WorkerPool { - if workers <= 0 { - workers = runtime.NumCPU() - } - - wp := &WorkerPool{ - workers: workers, - jobQueue: make(chan func(), workers*2), // Buffer for burst traffic - quit: make(chan bool), - } - - wp.start() - return wp -} - -func (wp *WorkerPool) start() { - for i := 0; i < wp.workers; i++ { - wp.wg.Add(1) - go func() { - defer wp.wg.Done() - for { - select { - case job := <-wp.jobQueue: - job() - case <-wp.quit: - return - } - } - }() - } -} - -func (wp *WorkerPool) Submit(job func()) { - wp.jobQueue <- job -} - -func (wp *WorkerPool) Stop() { - close(wp.quit) - wp.wg.Wait() -} - -// Global worker pool -var globalWorkerPool = NewWorkerPool(runtime.NumCPU() * 2) - -// Request coalescing for identical requests -type CoalescingCache struct { - requests map[string]chan interface{} - mutex sync.RWMutex -} - -func NewCoalescingCache() *CoalescingCache { - return &CoalescingCache{ - requests: make(map[string]chan interface{}), - } -} - -func (cc *CoalescingCache) getRequestKey(method, url string, body []byte) string { - h := sha256.New() - h.Write([]byte(method)) - h.Write([]byte(url)) - h.Write(body) - return hex.EncodeToString(h.Sum(nil)) -} - -func (cc *CoalescingCache) CoalesceRequest(key string, fn func() interface{}) interface{} { - cc.mutex.Lock() - - // Check if request is already in progress - if ch, exists := cc.requests[key]; exists { - cc.mutex.Unlock() - // Wait for the existing request to complete - return <-ch - } - - // Create new channel for this request - ch := make(chan interface{}, 1) - cc.requests[key] = ch - cc.mutex.Unlock() - - // Execute the request - result := fn() - - // Broadcast result to all waiting goroutines - ch <- result - close(ch) - - // Clean up - cc.mutex.Lock() - delete(cc.requests, key) - cc.mutex.Unlock() - - return result -} - -// Global coalescing cache for models endpoint -var modelsCoalescingCache = NewCoalescingCache() - -func ensureValidToken(cfg *Config) error { - tokenMu.Lock() - defer tokenMu.Unlock() - - now := time.Now().Unix() - - // Check if token is completely missing - if cfg.CopilotToken == "" { - log.Printf("No Copilot token found, starting authentication") - return authenticate(cfg) - } - - // Proactive refresh: refresh when 20% of lifetime remains or <5 minutes - timeUntilExpiry := cfg.ExpiresAt - now - refreshThreshold := int64(300) // 5 minutes - if cfg.RefreshIn > 0 { - // Use 20% of RefreshIn as threshold, but minimum 5 minutes - proactiveThreshold := cfg.RefreshIn / 5 // 20% = 1/5 - if proactiveThreshold > refreshThreshold { - refreshThreshold = proactiveThreshold - } - } - - if timeUntilExpiry <= refreshThreshold { - log.Printf("Token expires in %d seconds (threshold: %d), attempting refresh", timeUntilExpiry, refreshThreshold) - if err := refreshToken(cfg); err != nil { - log.Printf("Token refresh failed, falling back to full authentication: %v", err) - return authenticate(cfg) - } - log.Printf("Token refresh completed successfully") - } else { - log.Printf("Token is valid: expires in %d seconds", timeUntilExpiry) - } - - return nil -} - -// isRetriableError determines if an HTTP error should be retried -func isRetriableError(statusCode int, err error) bool { - if err != nil { - return true // Network errors are retriable - } - - // Retry on server errors and rate limiting - return statusCode >= 500 || statusCode == 429 || statusCode == 408 -} - -// makeRequestWithRetry performs HTTP request with exponential backoff retry -func makeRequestWithRetry(client *http.Client, req *http.Request, body []byte) (*http.Response, error) { - var lastResp *http.Response - var lastErr error - - for attempt := 1; attempt <= maxChatRetries; attempt++ { - // Create a new request for each attempt (in case body was consumed) - retryReq, err := http.NewRequest(req.Method, req.URL.String(), bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - - // Copy all headers - for key, values := range req.Header { - for _, value := range values { - retryReq.Header.Add(key, value) - } - } - - log.Printf("Chat completion attempt %d/%d", attempt, maxChatRetries) - - resp, err := client.Do(retryReq) - if err != nil { - lastErr = err - if attempt == maxChatRetries { - log.Printf("Request failed after %d attempts: %v", maxChatRetries, err) - return nil, err - } - - waitTime := time.Duration(baseChatRetryDelay*attempt*attempt) * time.Second - log.Printf("Request failed (attempt %d), retrying in %v: %v", attempt, waitTime, err) - time.Sleep(waitTime) - continue - } - - lastResp = resp - - // Check if we should retry based on status code - if !isRetriableError(resp.StatusCode, nil) { - log.Printf("Request successful on attempt %d: %d", attempt, resp.StatusCode) - return resp, nil - } - - // Close the response body before retrying - resp.Body.Close() - - if attempt == maxChatRetries { - log.Printf("Request failed after %d attempts with status: %d", maxChatRetries, resp.StatusCode) - return resp, nil // Return the last response even if it failed - } - - waitTime := time.Duration(baseChatRetryDelay*attempt*attempt) * time.Second - log.Printf("Request failed with status %d (attempt %d), retrying in %v", resp.StatusCode, attempt, waitTime) - time.Sleep(waitTime) - } - - return lastResp, lastErr -} - -func proxyHandler(cfg *Config) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Create context with extended timeout for long-lived streaming responses - ctx, cancel := context.WithTimeout(r.Context(), time.Duration(cfg.Timeouts.ProxyContext)*time.Second) - defer cancel() - - // Check circuit breaker - if !circuitBreaker.canExecute() { - log.Printf("Circuit breaker is open, rejecting request") - http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable) - return - } - - // Limit request body size to 5MB - r.Body = http.MaxBytesReader(w, r.Body, 5*1024*1024) - - // For streaming responses, we need to handle them differently - // Use a response wrapper to track if headers have been sent - respWrapper := &responseWrapper{ResponseWriter: w, headersSent: false} - - // Create a done channel to track completion - done := make(chan error, 1) - - // Submit request to worker pool - globalWorkerPool.Submit(func() { - defer func() { - if recovery := recover(); recovery != nil { - log.Printf("Worker panic recovered: %v", recovery) - done <- fmt.Errorf("internal server error") - } - }() - - err := processProxyRequest(cfg, respWrapper, r, ctx) - done <- err - }) - - // Wait for worker to complete or context timeout - select { - case err := <-done: - if err != nil { - log.Printf("Worker error: %v", err) - // Only write error if headers haven't been sent - if !respWrapper.headersSent { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } - case <-ctx.Done(): - log.Printf("Request timeout in worker pool") - // Only write timeout error if headers haven't been sent - if !respWrapper.headersSent { - http.Error(w, "Request timeout", http.StatusRequestTimeout) - } - } - } -} - -// Response wrapper to track if headers have been sent -type responseWrapper struct { - http.ResponseWriter - headersSent bool -} - -func (rw *responseWrapper) WriteHeader(statusCode int) { - if !rw.headersSent { - rw.headersSent = true - rw.ResponseWriter.WriteHeader(statusCode) - } -} - -func (rw *responseWrapper) Write(data []byte) (int, error) { - if !rw.headersSent { - rw.headersSent = true - } - return rw.ResponseWriter.Write(data) -} - -// Process proxy request in worker goroutine - returns error instead of using channel -func processProxyRequest(cfg *Config, w http.ResponseWriter, r *http.Request, ctx context.Context) error { - log.Printf("Starting processProxyRequest for %s %s", r.Method, r.URL.Path) - - if err := ensureValidToken(cfg); err != nil { - log.Printf("Token validation failed: %v", err) - return fmt.Errorf("authentication required") - } - - // Log request - log.Printf("Request: %s %s", r.Method, r.URL.Path) - log.Printf("Request Content-Length: %d", r.ContentLength) - - // Read the request body - body, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("Error reading request body: %v", err) - return fmt.Errorf("error reading request") - } - defer r.Body.Close() - - // Transform path - targetPath := "/chat/completions" - if r.URL.Path == "/v1/chat/completions" { - targetPath = "/chat/completions" - } - - // Create new request to GitHub Copilot with context - targetURL := copilotAPIBase + targetPath - log.Printf("Sending to: %s", targetURL) - log.Printf("Request body length: %d", len(body)) - - req, err := http.NewRequestWithContext(ctx, r.Method, targetURL, bytes.NewBuffer(body)) - if err != nil { - log.Printf("Error creating request: %v", err) - return fmt.Errorf("error creating request") - } - - // Set headers exactly as the working direct approach - req.Header.Set("Authorization", "Bearer "+cfg.CopilotToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "GitHubCopilotChat/0.26.7") - req.Header.Set("Editor-Version", "vscode/1.99.3") - req.Header.Set("Editor-Plugin-Version", "copilot-chat/0.26.7") - req.Header.Set("Copilot-Integration-Id", "vscode-chat") - req.Header.Set("Openai-Intent", "conversation-edits") - req.Header.Set("X-Initiator", "user") - - // Make the request with retry logic using shared client - resp, err := makeRequestWithRetry(sharedHTTPClient, req, body) - if err != nil { - circuitBreaker.onFailure() - log.Printf("Error making request after retries: %v", err) - return fmt.Errorf("error making request") - } - defer resp.Body.Close() - - // Success - notify circuit breaker - if resp.StatusCode < 500 { - circuitBreaker.onSuccess() - } else { - circuitBreaker.onFailure() - } - - log.Printf("Response: %d - Content-Type: %s", resp.StatusCode, resp.Header.Get("Content-Type")) - - // Copy response headers - for key, values := range resp.Header { - for _, value := range values { - w.Header().Add(key, value) - } - } - - // Add CORS headers - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "*") - - // Copy status code - w.WriteHeader(resp.StatusCode) - - // For streaming responses, use direct copy without buffer pooling - // to avoid blocking the stream - if resp.Header.Get("Content-Type") == "text/event-stream" { - log.Printf("Starting streaming response copy") - // Stream directly for event-stream responses with flushing support - if flusher, ok := w.(http.Flusher); ok { - // Copy in chunks and flush periodically for better streaming - buf := make([]byte, 1024) // Small buffer for streaming - for { - n, err := resp.Body.Read(buf) - if n > 0 { - _, writeErr := w.Write(buf[:n]) - if writeErr != nil { - log.Printf("Error writing streaming chunk: %v", writeErr) - return writeErr - } - flusher.Flush() // Flush immediately for streaming - } - if err == io.EOF { - log.Printf("Streaming response completed successfully") - break - } - if err != nil { - log.Printf("Error reading streaming response: %v", err) - return err - } - } - } else { - // Fallback to direct copy if no flusher available - _, err = io.Copy(w, resp.Body) - } - } else { - log.Printf("Starting regular response copy") - // Use buffer pool for regular responses - buf := bufferPool.Get().(*bytes.Buffer) - buf.Reset() - defer bufferPool.Put(buf) - _, err = io.CopyBuffer(w, resp.Body, buf.Bytes()[:0]) - } - - if err != nil { - log.Printf("Error copying response: %v", err) - return err - } - - // Signal successful completion - log.Printf("processProxyRequest completed successfully") - return nil -} diff --git a/server.go b/server.go deleted file mode 100644 index dcbf187..0000000 --- a/server.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" -) - -func setupGracefulShutdown(server *http.Server) { - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - - go func() { - <-c - fmt.Println("\nGracefully shutting down...") - - // Stop worker pool - globalWorkerPool.Stop() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := server.Shutdown(ctx); err != nil { - log.Printf("Server shutdown error: %v", err) - } - }() -} - -func healthHandler(w http.ResponseWriter, r *http.Request) { - response := map[string]interface{}{ - "status": "ok", - "service": "github-copilot-svcs", - "timestamp": time.Now().Unix(), - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -func setupLogging() { - log.SetFlags(log.LstdFlags | log.Lshortfile) - log.SetPrefix("[github-copilot-svcs] ") -} diff --git a/test/fixtures/config/valid_config.json b/test/fixtures/config/valid_config.json new file mode 100644 index 0000000..63c0c73 --- /dev/null +++ b/test/fixtures/config/valid_config.json @@ -0,0 +1,27 @@ +{ + "port": 8081, + "github_token": "", + "copilot_token": "", + "expires_at": 0, + "refresh_in": 3600, + "headers": { + "user_agent": "GitHubCopilotChat/0.29.1", + "editor_version": "vscode/1.102.3", + "editor_plugin_version": "copilot-chat/0.29.1", + "copilot_integration_id": "vscode-chat", + "openai_intent": "conversation-edits", + "x_initiator": "user" + }, + "timeouts": { + "http_client": 300, + "server_read": 30, + "server_write": 300, + "server_idle": 120, + "proxy_context": 300, + "circuit_breaker": 30, + "keep_alive": 30, + "tls_handshake": 10, + "dial_timeout": 10, + "idle_conn_timeout": 90 + } +} diff --git a/test/fixtures/responses/models_response.json b/test/fixtures/responses/models_response.json new file mode 100644 index 0000000..e558d9e --- /dev/null +++ b/test/fixtures/responses/models_response.json @@ -0,0 +1,23 @@ +{ + "object": "list", + "data": [ + { + "id": "gpt-4o", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "claude-3.5-sonnet", + "object": "model", + "created": 1687882411, + "owned_by": "anthropic" + }, + { + "id": "gemini-2.0-flash-001", + "object": "model", + "created": 1687882411, + "owned_by": "google" + } + ] +} diff --git a/test/integration/api_test.go b/test/integration/api_test.go new file mode 100644 index 0000000..6c43e51 --- /dev/null +++ b/test/integration/api_test.go @@ -0,0 +1,870 @@ +package integration_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/privapps/github-copilot-svcs/internal" +) + +var ( + testServer *internal.Server + baseURL string + cleanup func() +) + +// TestMain sets up and tears down the test server for all integration tests +func TestMain(m *testing.M) { + // Set up test server + var err error + testServer, baseURL, cleanup, err = setupTestServer() + if err != nil { + fmt.Printf("Failed to setup test server: %v\n", err) + os.Exit(1) + } + + // Wait for server to be ready + if !waitForServer(baseURL, 15*time.Second) { + cleanup() + fmt.Println("Server failed to start within timeout") + os.Exit(1) + } + + fmt.Printf("Test server ready at %s\n", baseURL) + + // Run tests + code := m.Run() + + // Cleanup + cleanup() + + os.Exit(code) +} + +func TestHealthEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + expectedStatus int + expectedFields []string + }{ + { + name: "basic health check", + endpoint: "/health", + expectedStatus: http.StatusOK, + expectedFields: []string{"status", "timestamp", "version"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := http.Get(baseURL + tt.endpoint) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, resp.StatusCode) + } + + // Check response body is valid JSON with expected fields + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Errorf("Failed to decode JSON response: %v", err) + return + } + + for _, field := range tt.expectedFields { + if _, exists := result[field]; !exists { + t.Errorf("Expected field '%s' not found in response", field) + } + } + + // Verify status is "healthy" + if status, ok := result["status"].(string); !ok || status != "healthy" { + t.Errorf("Expected status 'healthy', got %v", result["status"]) + } + }) + } +} + +func TestModelsEndpoint(t *testing.T) { + tests := []struct { + name string + method string + endpoint string + expectedStatus int + checkJSON bool + expectedFields []string + }{ + { + name: "get models list", + method: "GET", + endpoint: "/v1/models", + expectedStatus: http.StatusOK, + checkJSON: true, + expectedFields: []string{"object", "data"}, + }, + { + name: "models endpoint with POST method", + method: "POST", + endpoint: "/v1/models", + expectedStatus: http.StatusOK, // Models endpoint accepts POST + checkJSON: true, + expectedFields: []string{"object", "data"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.method, baseURL+tt.endpoint, http.NoBody) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + 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 { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected status %d, got %d. Response: %s", tt.expectedStatus, resp.StatusCode, string(body)) + } + + if tt.checkJSON { + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Errorf("Failed to decode JSON response: %v", err) + return + } + + for _, field := range tt.expectedFields { + if _, exists := result[field]; !exists { + t.Errorf("Expected field '%s' not found in response", field) + } + } + + // Check that data is an array + if data, ok := result["data"].([]interface{}); !ok { + t.Errorf("Expected 'data' to be an array, got %T", result["data"]) + } else if len(data) == 0 { + t.Log("Note: Models list is empty - this may be expected in test environment") + } + } + }) + } +} + +func TestChatCompletionsEndpoint(t *testing.T) { + tests := []struct { + name string + method string + endpoint string + body string + expectedStatus int + contentType string + }{ + { + name: "chat completions with empty body", + method: "POST", + endpoint: "/v1/chat/completions", + body: "", + expectedStatus: http.StatusBadRequest, + contentType: "application/json", + }, + { + name: "chat completions with invalid JSON", + method: "POST", + endpoint: "/v1/chat/completions", + body: `{"invalid": json}`, + expectedStatus: http.StatusBadRequest, + contentType: "application/json", + }, + { + name: "chat completions with wrong method", + method: "GET", + endpoint: "/v1/chat/completions", + body: "", + expectedStatus: http.StatusMethodNotAllowed, + contentType: "application/json", + }, + { + name: "chat completions with basic valid request", + method: "POST", + endpoint: "/v1/chat/completions", + body: `{"model":"gpt-4","messages":[{"role":"user","content":"test"}]}`, + 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)) + } + }) + } +} + +// 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 + endpoint string + origin string + method string + expectedStatus int + checkCORS bool + }{ + { + name: "CORS preflight request", + endpoint: "/v1/models", + origin: "http://localhost:3000", + method: "OPTIONS", + expectedStatus: http.StatusOK, + checkCORS: true, + }, + { + name: "CORS actual request", + endpoint: "/health", + origin: "http://localhost:3000", + method: "GET", + expectedStatus: http.StatusOK, + checkCORS: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.method, baseURL+tt.endpoint, http.NoBody) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + + if tt.method == "OPTIONS" { + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "Content-Type") + } + + client := &http.Client{Timeout: 5 * 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 { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, resp.StatusCode) + } + + if tt.checkCORS { + // Check for CORS headers + allowOrigin := resp.Header.Get("Access-Control-Allow-Origin") + if allowOrigin == "" { + t.Error("Expected Access-Control-Allow-Origin header") + } + + if tt.method == "OPTIONS" { + allowMethods := resp.Header.Get("Access-Control-Allow-Methods") + if allowMethods == "" { + t.Error("Expected Access-Control-Allow-Methods header for preflight") + } + } + } + }) + } +} + +func TestErrorConditions(t *testing.T) { + tests := []struct { + name string + endpoint string + expectedStatus int + }{ + { + name: "nonexistent endpoint", + endpoint: "/nonexistent", + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid path", + endpoint: "/v1/invalid", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := http.Get(baseURL + tt.endpoint) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, resp.StatusCode) + } + }) + } +} + +func TestSecurityHeaders(t *testing.T) { + resp, err := http.Get(baseURL + "/health") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + expectedHeaders := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + } + + for header, expectedValue := range expectedHeaders { + actualValue := resp.Header.Get(header) + if actualValue != expectedValue { + t.Errorf("Expected header %s to be '%s', got '%s'", header, expectedValue, actualValue) + } + } +} + +func TestServerShutdown(t *testing.T) { + // This test verifies that the server can be gracefully shut down + // We'll create a separate server instance for this test + server, serverURL, shutdownFunc, err := setupTestServer() + if err != nil { + t.Fatalf("Failed to setup test server: %v", err) + } + + // Wait for server to be ready + if !waitForServer(serverURL, 5*time.Second) { + shutdownFunc() + t.Fatal("Server failed to start within timeout") + } + + // Make a request to ensure server is working + resp, err := http.Get(serverURL + "/health") + if err != nil { + shutdownFunc() + t.Fatalf("Failed to make request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Shutdown the server + shutdownFunc() + + // Verify server is no longer responding + time.Sleep(200 * time.Millisecond) // Give time for shutdown + resp2, err := http.Get(serverURL + "/health") + if resp2 != nil { + defer resp2.Body.Close() + } + if err == nil { + t.Error("Expected server to be shut down, but it's still responding") + } + + _ = server // Use server variable to avoid unused warning +} + +// setupTestServer creates a test server instance and returns cleanup function +func setupTestServer() (server *internal.Server, baseURL string, cleanup func(), err error) { + // Find an available port + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, "", nil, fmt.Errorf("failed to find available port: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + // Create test configuration with proper defaults + cfg := &internal.Config{ + Port: port, + } + + // Set default headers to prevent validation errors + internal.SetDefaultHeaders(cfg) + internal.SetDefaultCORS(cfg) + internal.SetDefaultTimeouts(cfg) + + // Create HTTP client for the server + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + // Create server instance + server = internal.NewServer(cfg, httpClient) + baseURL = fmt.Sprintf("http://localhost:%d", port) + + // Start server in background goroutine + serverErrCh := make(chan error, 1) + + go func() { + // For testing, we'll just call Start() which blocks + if err := server.Start(); err != nil && err != http.ErrServerClosed { + serverErrCh <- err + } + }() + + cleanup = func() { + if server != nil { + if err := server.Stop(); err != nil { + fmt.Printf("Error stopping server: %v\n", err) + } + } + // Give server time to shutdown gracefully + time.Sleep(200 * time.Millisecond) + } + + // Check for immediate startup errors + select { + case err := <-serverErrCh: + cleanup() + return nil, "", nil, fmt.Errorf("server failed to start: %w", err) + case <-time.After(1 * time.Second): + // Server seems to be starting OK + } + + return server, baseURL, cleanup, nil +} + +// waitForServer waits for the server to be ready to accept connections +func waitForServer(baseURL string, timeout time.Duration) bool { + client := &http.Client{Timeout: 1 * time.Second} + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + resp, err := client.Get(baseURL + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true + } + } + time.Sleep(200 * time.Millisecond) + } + 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 new file mode 100644 index 0000000..ceced26 --- /dev/null +++ b/test/testutils/helpers.go @@ -0,0 +1,145 @@ +// Package testutils provides helpers for testing github-copilot-svcs. +package testutils + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/privapps/github-copilot-svcs/internal" +) + +// MockConfig creates a test configuration +const ( + testPort = 8080 + testExpiresAt = 1000000000 + testRefreshIn = 3600 +) + +// MockConfig returns a test configuration for use in unit tests. +func MockConfig() *internal.Config { + cfg := &internal.Config{ + Port: testPort, + GitHubToken: "test-github-token", + CopilotToken: "test-copilot-token", + ExpiresAt: testExpiresAt, + RefreshIn: testRefreshIn, + } + + internal.SetDefaultTimeouts(cfg) + internal.SetDefaultHeaders(cfg) + + return cfg +} + +// LoadFixture loads a test fixture file +func LoadFixture(t *testing.T, path string) []byte { + t.Helper() + + fixturePath := filepath.Join("..", "fixtures", path) + data, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to load fixture %s: %v", path, err) + } + return data +} + +// SetupTestDir creates a temporary directory for tests +func SetupTestDir(t *testing.T) string { + t.Helper() + + dir, err := os.MkdirTemp("", "copilot-test-") + if err != nil { + t.Fatalf("failed to create test dir: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + panic(err) + } + }) + + return dir +} + +// MockGitHubServer creates a mock GitHub API server +func MockGitHubServer() *httptest.Server { + mux := http.NewServeMux() + + // Mock models endpoint + mux.HandleFunc("/models", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{ + "object": "list", + "data": [ + { + "id": "gpt-4", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + } + ] + }`)); err != nil { + panic(err) + } + }) + + // Mock auth endpoint + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "Bearer valid-token" { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"login": "testuser"}`)); err != nil { + panic(err) + } + } else { + w.WriteHeader(http.StatusUnauthorized) + } + }) + + return httptest.NewServer(mux) +} + +// SetupValidToken sets up environment for valid token tests +func SetupValidToken() { + if err := os.Setenv("GITHUB_TOKEN", "valid-token"); err != nil { + panic(err) + } +} + +// SetupInvalidToken sets up environment for invalid token tests +func SetupInvalidToken() { + if err := os.Setenv("GITHUB_TOKEN", "invalid-token"); err != nil { + panic(err) + } +} + +// CleanupEnv cleans up test environment variables +func CleanupEnv() { + if err := os.Unsetenv("GITHUB_TOKEN"); err != nil { + panic(err) + } + if err := os.Unsetenv("COPILOT_TOKEN"); err != nil { + panic(err) + } + if err := os.Unsetenv("COPILOT_PORT"); err != nil { + panic(err) + } + if err := os.Unsetenv("LOG_LEVEL"); err != nil { + panic(err) + } +} + +// InitLogger initializes the logger for tests +func InitLogger() { + internal.Init() +} + +// CreateTestServer creates a test server with the given config +func CreateTestServer(cfg *internal.Config) *internal.Server { + httpClient := internal.CreateHTTPClient(cfg) + return internal.NewServer(cfg, httpClient) +} 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!"