imseldrith commited on
Commit
9e27976
·
verified ·
1 Parent(s): b178576

Initial upload from Colab

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +13 -0
  2. .github/FUNDING.yml +15 -0
  3. .github/workflows/ci.yml +32 -0
  4. .github/workflows/deploy-pages.yml +40 -0
  5. .github/workflows/release-docker.yml +91 -0
  6. .github/workflows/release.yml +26 -0
  7. .gitignore +14 -0
  8. .vscode/settings.json +8 -0
  9. AGENTS.md +47 -0
  10. Dockerfile +25 -0
  11. LICENSE +21 -0
  12. README.md +348 -9
  13. bun.lock +0 -0
  14. entrypoint.sh +9 -0
  15. eslint.config.js +7 -0
  16. opencode.json +20 -0
  17. package.json +68 -0
  18. pages/index.html +556 -0
  19. src/auth.ts +52 -0
  20. src/check-usage.ts +58 -0
  21. src/debug.ts +127 -0
  22. src/lib/api-config.ts +52 -0
  23. src/lib/approval.ts +15 -0
  24. src/lib/error.ts +47 -0
  25. src/lib/paths.ts +26 -0
  26. src/lib/proxy.ts +66 -0
  27. src/lib/rate-limit.ts +46 -0
  28. src/lib/shell.ts +88 -0
  29. src/lib/state.ts +25 -0
  30. src/lib/token.ts +95 -0
  31. src/lib/tokenizer.ts +348 -0
  32. src/lib/utils.ts +26 -0
  33. src/main.ts +19 -0
  34. src/routes/chat-completions/handler.ts +68 -0
  35. src/routes/chat-completions/route.ts +15 -0
  36. src/routes/embeddings/route.ts +20 -0
  37. src/routes/messages/anthropic-types.ts +206 -0
  38. src/routes/messages/count-tokens-handler.ts +70 -0
  39. src/routes/messages/handler.ts +91 -0
  40. src/routes/messages/non-stream-translation.ts +357 -0
  41. src/routes/messages/route.ts +24 -0
  42. src/routes/messages/stream-translation.ts +190 -0
  43. src/routes/messages/utils.ts +16 -0
  44. src/routes/models/route.ts +34 -0
  45. src/routes/token/route.ts +16 -0
  46. src/routes/usage/route.ts +15 -0
  47. src/server.ts +31 -0
  48. src/services/copilot/create-chat-completions.ts +193 -0
  49. src/services/copilot/create-embeddings.ts +38 -0
  50. src/services/copilot/get-models.ts +55 -0
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+
3
+ .vscode/
4
+
5
+ .git/
6
+ .github/
7
+ .gitignore
8
+
9
+ dist/
10
+ tests/
11
+ *.md
12
+
13
+ .eslintcache
.github/FUNDING.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # These are supported funding model platforms
2
+
3
+ github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: ericc_ch
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
.github/workflows/ci.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ types: [opened, synchronize, reopened]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: oven-sh/setup-bun@v2
16
+ with:
17
+ bun-version: latest
18
+
19
+ - name: Install dependencies
20
+ run: bun install
21
+
22
+ - name: Run linter
23
+ run: bun run lint:all
24
+
25
+ - name: Run type check
26
+ run: bun run typecheck
27
+
28
+ - name: Run tests
29
+ run: bun test
30
+
31
+ - name: Build
32
+ run: bun run build
.github/workflows/deploy-pages.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ workflow_dispatch:
7
+
8
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+
14
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
15
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
16
+ concurrency:
17
+ group: "pages"
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ deploy:
22
+ runs-on: ubuntu-latest
23
+ environment:
24
+ name: github-pages
25
+ url: ${{ steps.deployment.outputs.page_url }}
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@v4
29
+
30
+ - name: Setup Pages
31
+ uses: actions/configure-pages@v4
32
+
33
+ - name: Upload artifact
34
+ uses: actions/upload-pages-artifact@v3
35
+ with:
36
+ path: ./pages
37
+
38
+ - name: Deploy to GitHub Pages
39
+ id: deployment
40
+ uses: actions/deploy-pages@v4
.github/workflows/release-docker.yml ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Build and Push
2
+
3
+ # This workflow uses actions that are not certified by GitHub.
4
+ # They are provided by a third-party and are governed by
5
+ # separate terms of service, privacy policy, and support
6
+ # documentation.
7
+
8
+ on:
9
+ push:
10
+ # branches: [ "main" ]
11
+ # Publish semver tags as releases.
12
+ tags: [ 'v*.*.*' ]
13
+ paths-ignore:
14
+ - 'docs/**'
15
+
16
+ env:
17
+ # Use docker.io for Docker Hub if empty
18
+ REGISTRY: ghcr.io
19
+ # github.repository as <account>/<repo>
20
+ #IMAGE_NAME: ${{ github.repository }}
21
+
22
+
23
+ jobs:
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ packages: write
29
+ # This is used to complete the identity challenge
30
+ # with sigstore/fulcio when running outside of PRs.
31
+ id-token: write
32
+
33
+ steps:
34
+ - name: Checkout repository
35
+ uses: actions/checkout@v3
36
+
37
+ - name: Set version
38
+ id: version
39
+ run: |
40
+ mkdir -p handlers
41
+ echo ${GITHUB_REF#refs/tags/v} > handlers/VERSION
42
+
43
+ # Install the cosign tool except on PR
44
+ # https://github.com/sigstore/cosign-installer
45
+ - name: Install cosign
46
+ if: github.event_name != 'pull_request'
47
+ uses: sigstore/cosign-installer@main
48
+ - name: Set up QEMU
49
+ uses: docker/setup-qemu-action@v2
50
+ with:
51
+ platforms: 'arm64,amd64'
52
+
53
+ # Workaround: https://github.com/docker/build-push-action/issues/461
54
+ - name: Setup Docker buildx
55
+ uses: docker/setup-buildx-action@v2
56
+
57
+ # Login against a Docker registry except on PR
58
+ # https://github.com/docker/login-action
59
+ - name: Log into registry ${{ env.REGISTRY }}
60
+ if: github.event_name != 'pull_request'
61
+ uses: docker/login-action@v2
62
+ with:
63
+ registry: ${{ env.REGISTRY }}
64
+ username: ${{ github.actor }}
65
+ password: ${{ secrets.GITHUB_TOKEN }}
66
+
67
+ # Extract metadata (tags, labels) for Docker
68
+ # https://github.com/docker/metadata-action
69
+ - name: Extract Docker metadata
70
+ id: meta
71
+ uses: docker/metadata-action@v4
72
+ with:
73
+ github-token: ${{ secrets.GITHUB_TOKEN }}
74
+ images: ${{ env.REGISTRY }}/${{ github.repository }}
75
+ tags: |
76
+ type=semver,pattern=v{{version}}
77
+ type=semver,pattern=v{{major}}.{{minor}}
78
+ type=semver,pattern=v{{major}}
79
+
80
+ # Build and push Docker image with Buildx (don't push on PR)
81
+ # https://github.com/docker/build-push-action
82
+ - name: Build and push Docker image
83
+ id: build-and-push
84
+ uses: docker/build-push-action@v3
85
+ with:
86
+ context: .
87
+ push: ${{ github.event_name != 'pull_request' }}
88
+ tags: ${{ steps.meta.outputs.tags }}
89
+ platforms: linux/amd64,linux/arm64
90
+ labels: ${{ steps.meta.outputs.labels }}
91
+
.github/workflows/release.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release
2
+
3
+ permissions:
4
+ id-token: write
5
+ contents: write
6
+
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*"
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - uses: oven-sh/setup-bun@v2
21
+ with:
22
+ bun-version: latest
23
+
24
+ - run: bunx changelogithub
25
+ env:
26
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # deps
2
+ node_modules/
3
+
4
+ # local env
5
+ *.local
6
+
7
+ # aider
8
+ .aider*
9
+
10
+ # eslint cache
11
+ .eslintcache
12
+
13
+ # build output
14
+ dist/
.vscode/settings.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "deno.enable": false,
3
+ "editor.codeActionsOnSave": {
4
+ "source.fixAll.eslint": "explicit",
5
+ "source.organizeImports": "never"
6
+ },
7
+ "typescript.tsdk": "node_modules/typescript/lib"
8
+ }
AGENTS.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md
2
+
3
+ ## Build, Lint, and Test Commands
4
+
5
+ - **Build:**
6
+ `bun run build` (uses tsup)
7
+ - **Dev:**
8
+ `bun run dev`
9
+ - **Lint:**
10
+ `bun run lint` (uses @echristian/eslint-config)
11
+ - **Lint & Fix staged files:**
12
+ `bunx lint-staged`
13
+ - **Test all:**
14
+ `bun test`
15
+ - **Test single file:**
16
+ `bun test tests/claude-request.test.ts`
17
+ - **Start (prod):**
18
+ `bun run start`
19
+
20
+ ## Code Style Guidelines
21
+
22
+ - **Imports:**
23
+ Use ESNext syntax. Prefer absolute imports via `~/*` for `src/*` (see `tsconfig.json`).
24
+ - **Formatting:**
25
+ Follows Prettier (with `prettier-plugin-packagejson`). Run `bun run lint` to auto-fix.
26
+ - **Types:**
27
+ Strict TypeScript (`strict: true`). Avoid `any`; use explicit types and interfaces.
28
+ - **Naming:**
29
+ Use `camelCase` for variables/functions, `PascalCase` for types/classes.
30
+ - **Error Handling:**
31
+ Use explicit error classes (see `src/lib/error.ts`). Avoid silent failures.
32
+ - **Unused:**
33
+ Unused imports/variables are errors (`noUnusedLocals`, `noUnusedParameters`).
34
+ - **Switches:**
35
+ No fallthrough in switch statements.
36
+ - **Modules:**
37
+ Use ESNext modules, no CommonJS.
38
+ - **Testing:**
39
+ Use Bun's built-in test runner. Place tests in `tests/`, name as `*.test.ts`.
40
+ - **Linting:**
41
+ Uses `@echristian/eslint-config` (see npm for details). Includes stylistic, unused imports, regex, and package.json rules.
42
+ - **Paths:**
43
+ Use path aliases (`~/*`) for imports from `src/`.
44
+
45
+ ---
46
+
47
+ This file is tailored for agentic coding agents. For more details, see the configs in `eslint.config.js` and `tsconfig.json`. No Cursor or Copilot rules detected.
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM oven/bun:1.2.19-alpine AS builder
2
+ WORKDIR /app
3
+
4
+ COPY ./package.json ./bun.lock ./
5
+ RUN bun install --frozen-lockfile
6
+
7
+ COPY . .
8
+ RUN bun run build
9
+
10
+ FROM oven/bun:1.2.19-alpine AS runner
11
+ WORKDIR /app
12
+
13
+ COPY ./package.json ./bun.lock ./
14
+ RUN bun install --frozen-lockfile --production --ignore-scripts --no-cache
15
+
16
+ COPY --from=builder /app/dist ./dist
17
+
18
+ EXPOSE 4141
19
+
20
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
21
+ CMD wget --spider -q http://localhost:4141/ || exit 1
22
+
23
+ COPY entrypoint.sh /entrypoint.sh
24
+ RUN chmod +x /entrypoint.sh
25
+ ENTRYPOINT ["/entrypoint.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Erick Christian Purwanto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,12 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Copilot Api
3
- emoji: 🦀
4
- colorFrom: yellow
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: copilot-api
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copilot API Proxy
2
+
3
+ > [!WARNING]
4
+ > This is a reverse-engineered proxy of GitHub Copilot API. It is not supported by GitHub, and may break unexpectedly. Use at your own risk.
5
+
6
+ > [!WARNING]
7
+ > **GitHub Security Notice:**
8
+ > Excessive automated or scripted use of Copilot (including rapid or bulk requests, such as via automated tools) may trigger GitHub's abuse-detection systems.
9
+ > You may receive a warning from GitHub Security, and further anomalous activity could result in temporary suspension of your Copilot access.
10
+ >
11
+ > GitHub prohibits use of their servers for excessive automated bulk activity or any activity that places undue burden on their infrastructure.
12
+ >
13
+ > Please review:
14
+ >
15
+ > - [GitHub Acceptable Use Policies](https://docs.github.com/site-policy/acceptable-use-policies/github-acceptable-use-policies#4-spam-and-inauthentic-activity-on-github)
16
+ > - [GitHub Copilot Terms](https://docs.github.com/site-policy/github-terms/github-terms-for-additional-products-and-features#github-copilot)
17
+ >
18
+ > Use this proxy responsibly to avoid account restrictions.
19
+
20
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E519XS7W)
21
+
22
  ---
23
+
24
+ **Note:** If you are using [opencode](https://github.com/sst/opencode), you do not need this project. Opencode supports GitHub Copilot provider out of the box.
25
+
 
 
 
 
 
26
  ---
27
 
28
+ ## Project Overview
29
+
30
+ A reverse-engineered proxy for the GitHub Copilot API that exposes it as an OpenAI and Anthropic compatible service. This allows you to use GitHub Copilot with any tool that supports the OpenAI Chat Completions API or the Anthropic Messages API, including to power [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview).
31
+
32
+ ## Features
33
+
34
+ - **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API.
35
+ - **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`).
36
+ - **Usage Dashboard**: A web-based dashboard to monitor your Copilot API usage, view quotas, and see detailed statistics.
37
+ - **Rate Limit Control**: Manage API usage with rate-limiting options (`--rate-limit`) and a waiting mechanism (`--wait`) to prevent errors from rapid requests.
38
+ - **Manual Request Approval**: Manually approve or deny each API request for fine-grained control over usage (`--manual`).
39
+ - **Token Visibility**: Option to display GitHub and Copilot tokens during authentication and refresh for debugging (`--show-token`).
40
+ - **Flexible Authentication**: Authenticate interactively or provide a GitHub token directly, suitable for CI/CD environments.
41
+ - **Support for Different Account Types**: Works with individual, business, and enterprise GitHub Copilot plans.
42
+
43
+ ## Demo
44
+
45
+ https://github.com/user-attachments/assets/7654b383-669d-4eb9-b23c-06d7aefee8c5
46
+
47
+ ## Prerequisites
48
+
49
+ - Bun (>= 1.2.x)
50
+ - GitHub account with Copilot subscription (individual, business, or enterprise)
51
+
52
+ ## Installation
53
+
54
+ To install dependencies, run:
55
+
56
+ ```sh
57
+ bun install
58
+ ```
59
+
60
+ ## Using with Docker
61
+
62
+ Build image
63
+
64
+ ```sh
65
+ docker build -t copilot-api .
66
+ ```
67
+
68
+ Run the container
69
+
70
+ ```sh
71
+ # Create a directory on your host to persist the GitHub token and related data
72
+ mkdir -p ./copilot-data
73
+
74
+ # Run the container with a bind mount to persist the token
75
+ # This ensures your authentication survives container restarts
76
+
77
+ docker run -p 4141:4141 -v $(pwd)/copilot-data:/root/.local/share/copilot-api copilot-api
78
+ ```
79
+
80
+ > **Note:**
81
+ > The GitHub token and related data will be stored in `copilot-data` on your host. This is mapped to `/root/.local/share/copilot-api` inside the container, ensuring persistence across restarts.
82
+
83
+ ### Docker with Environment Variables
84
+
85
+ You can pass the GitHub token directly to the container using environment variables:
86
+
87
+ ```sh
88
+ # Build with GitHub token
89
+ docker build --build-arg GH_TOKEN=your_github_token_here -t copilot-api .
90
+
91
+ # Run with GitHub token
92
+ docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-api
93
+
94
+ # Run with additional options
95
+ docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-api start --verbose --port 4141
96
+ ```
97
+
98
+ ### Docker Compose Example
99
+
100
+ ```yaml
101
+ version: "3.8"
102
+ services:
103
+ copilot-api:
104
+ build: .
105
+ ports:
106
+ - "4141:4141"
107
+ environment:
108
+ - GH_TOKEN=your_github_token_here
109
+ restart: unless-stopped
110
+ ```
111
+
112
+ The Docker image includes:
113
+
114
+ - Multi-stage build for optimized image size
115
+ - Non-root user for enhanced security
116
+ - Health check for container monitoring
117
+ - Pinned base image version for reproducible builds
118
+
119
+ ## Using with npx
120
+
121
+ You can run the project directly using npx:
122
+
123
+ ```sh
124
+ npx copilot-api@latest start
125
+ ```
126
+
127
+ With options:
128
+
129
+ ```sh
130
+ npx copilot-api@latest start --port 8080
131
+ ```
132
+
133
+ For authentication only:
134
+
135
+ ```sh
136
+ npx copilot-api@latest auth
137
+ ```
138
+
139
+ ## Command Structure
140
+
141
+ Copilot API now uses a subcommand structure with these main commands:
142
+
143
+ - `start`: Start the Copilot API server. This command will also handle authentication if needed.
144
+ - `auth`: Run GitHub authentication flow without starting the server. This is typically used if you need to generate a token for use with the `--github-token` option, especially in non-interactive environments.
145
+ - `check-usage`: Show your current GitHub Copilot usage and quota information directly in the terminal (no server required).
146
+ - `debug`: Display diagnostic information including version, runtime details, file paths, and authentication status. Useful for troubleshooting and support.
147
+
148
+ ## Command Line Options
149
+
150
+ ### Start Command Options
151
+
152
+ The following command line options are available for the `start` command:
153
+
154
+ | Option | Description | Default | Alias |
155
+ | -------------- | ----------------------------------------------------------------------------- | ---------- | ----- |
156
+ | --port | Port to listen on | 4141 | -p |
157
+ | --verbose | Enable verbose logging | false | -v |
158
+ | --account-type | Account type to use (individual, business, enterprise) | individual | -a |
159
+ | --manual | Enable manual request approval | false | none |
160
+ | --rate-limit | Rate limit in seconds between requests | none | -r |
161
+ | --wait | Wait instead of error when rate limit is hit | false | -w |
162
+ | --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g |
163
+ | --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c |
164
+ | --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none |
165
+ | --proxy-env | Initialize proxy from environment variables | false | none |
166
+
167
+ ### Auth Command Options
168
+
169
+ | Option | Description | Default | Alias |
170
+ | ------------ | ------------------------- | ------- | ----- |
171
+ | --verbose | Enable verbose logging | false | -v |
172
+ | --show-token | Show GitHub token on auth | false | none |
173
+
174
+ ### Debug Command Options
175
+
176
+ | Option | Description | Default | Alias |
177
+ | ------ | ------------------------- | ------- | ----- |
178
+ | --json | Output debug info as JSON | false | none |
179
+
180
+ ## API Endpoints
181
+
182
+ The server exposes several endpoints to interact with the Copilot API. It provides OpenAI-compatible endpoints and now also includes support for Anthropic-compatible endpoints, allowing for greater flexibility with different tools and services.
183
+
184
+ ### OpenAI Compatible Endpoints
185
+
186
+ These endpoints mimic the OpenAI API structure.
187
+
188
+ | Endpoint | Method | Description |
189
+ | --------------------------- | ------ | --------------------------------------------------------- |
190
+ | `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. |
191
+ | `GET /v1/models` | `GET` | Lists the currently available models. |
192
+ | `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. |
193
+
194
+ ### Anthropic Compatible Endpoints
195
+
196
+ These endpoints are designed to be compatible with the Anthropic Messages API.
197
+
198
+ | Endpoint | Method | Description |
199
+ | -------------------------------- | ------ | ------------------------------------------------------------ |
200
+ | `POST /v1/messages` | `POST` | Creates a model response for a given conversation. |
201
+ | `POST /v1/messages/count_tokens` | `POST` | Calculates the number of tokens for a given set of messages. |
202
+
203
+ ### Usage Monitoring Endpoints
204
+
205
+ New endpoints for monitoring your Copilot usage and quotas.
206
+
207
+ | Endpoint | Method | Description |
208
+ | ------------ | ------ | ------------------------------------------------------------ |
209
+ | `GET /usage` | `GET` | Get detailed Copilot usage statistics and quota information. |
210
+ | `GET /token` | `GET` | Get the current Copilot token being used by the API. |
211
+
212
+ ## Example Usage
213
+
214
+ Using with npx:
215
+
216
+ ```sh
217
+ # Basic usage with start command
218
+ npx copilot-api@latest start
219
+
220
+ # Run on custom port with verbose logging
221
+ npx copilot-api@latest start --port 8080 --verbose
222
+
223
+ # Use with a business plan GitHub account
224
+ npx copilot-api@latest start --account-type business
225
+
226
+ # Use with an enterprise plan GitHub account
227
+ npx copilot-api@latest start --account-type enterprise
228
+
229
+ # Enable manual approval for each request
230
+ npx copilot-api@latest start --manual
231
+
232
+ # Set rate limit to 30 seconds between requests
233
+ npx copilot-api@latest start --rate-limit 30
234
+
235
+ # Wait instead of error when rate limit is hit
236
+ npx copilot-api@latest start --rate-limit 30 --wait
237
+
238
+ # Provide GitHub token directly
239
+ npx copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE
240
+
241
+ # Run only the auth flow
242
+ npx copilot-api@latest auth
243
+
244
+ # Run auth flow with verbose logging
245
+ npx copilot-api@latest auth --verbose
246
+
247
+ # Show your Copilot usage/quota in the terminal (no server needed)
248
+ npx copilot-api@latest check-usage
249
+
250
+ # Display debug information for troubleshooting
251
+ npx copilot-api@latest debug
252
+
253
+ # Display debug information in JSON format
254
+ npx copilot-api@latest debug --json
255
+
256
+ # Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.)
257
+ npx copilot-api@latest start --proxy-env
258
+ ```
259
+
260
+ ## Using the Usage Viewer
261
+
262
+ After starting the server, a URL to the Copilot Usage Dashboard will be displayed in your console. This dashboard is a web interface for monitoring your API usage.
263
+
264
+ 1. Start the server. For example, using npx:
265
+ ```sh
266
+ npx copilot-api@latest start
267
+ ```
268
+ 2. The server will output a URL to the usage viewer. Copy and paste this URL into your browser. It will look something like this:
269
+ `https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage`
270
+ - If you use the `start.bat` script on Windows, this page will open automatically.
271
+
272
+ The dashboard provides a user-friendly interface to view your Copilot usage data:
273
+
274
+ - **API Endpoint URL**: The dashboard is pre-configured to fetch data from your local server endpoint via the URL query parameter. You can change this URL to point to any other compatible API endpoint.
275
+ - **Fetch Data**: Click the "Fetch" button to load or refresh the usage data. The dashboard will automatically fetch data on load.
276
+ - **Usage Quotas**: View a summary of your usage quotas for different services like Chat and Completions, displayed with progress bars for a quick overview.
277
+ - **Detailed Information**: See the full JSON response from the API for a detailed breakdown of all available usage statistics.
278
+ - **URL-based Configuration**: You can also specify the API endpoint directly in the URL using a query parameter. This is useful for bookmarks or sharing links. For example:
279
+ `https://ericc-ch.github.io/copilot-api?endpoint=http://your-api-server/usage`
280
+
281
+ ## Using with Claude Code
282
+
283
+ This proxy can be used to power [Claude Code](https://docs.anthropic.com/en/claude-code), an experimental conversational AI assistant for developers from Anthropic.
284
+
285
+ There are two ways to configure Claude Code to use this proxy:
286
+
287
+ ### Interactive Setup with `--claude-code` flag
288
+
289
+ To get started, run the `start` command with the `--claude-code` flag:
290
+
291
+ ```sh
292
+ npx copilot-api@latest start --claude-code
293
+ ```
294
+
295
+ You will be prompted to select a primary model and a "small, fast" model for background tasks. After selecting the models, a command will be copied to your clipboard. This command sets the necessary environment variables for Claude Code to use the proxy.
296
+
297
+ Paste and run this command in a new terminal to launch Claude Code.
298
+
299
+ ### Manual Configuration with `settings.json`
300
+
301
+ Alternatively, you can configure Claude Code by creating a `.claude/settings.json` file in your project's root directory. This file should contain the environment variables needed by Claude Code. This way you don't need to run the interactive setup every time.
302
+
303
+ Here is an example `.claude/settings.json` file:
304
+
305
+ ```json
306
+ {
307
+ "env": {
308
+ "ANTHROPIC_BASE_URL": "http://localhost:4141",
309
+ "ANTHROPIC_AUTH_TOKEN": "dummy",
310
+ "ANTHROPIC_MODEL": "gpt-4.1",
311
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-4.1",
312
+ "ANTHROPIC_SMALL_FAST_MODEL": "gpt-4.1",
313
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-4.1",
314
+ "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1",
315
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
316
+ },
317
+ "permissions": {
318
+ "deny": [
319
+ "WebSearch"
320
+ ]
321
+ }
322
+ }
323
+ ```
324
+
325
+ You can find more options here: [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables)
326
+
327
+ You can also read more about IDE integration here: [Add Claude Code to your IDE](https://docs.anthropic.com/en/docs/claude-code/ide-integrations)
328
+
329
+ ## Running from Source
330
+
331
+ The project can be run from source in several ways:
332
+
333
+ ### Development Mode
334
+
335
+ ```sh
336
+ bun run dev
337
+ ```
338
+
339
+ ### Production Mode
340
+
341
+ ```sh
342
+ bun run start
343
+ ```
344
+
345
+ ## Usage Tips
346
+
347
+ - To avoid hitting GitHub Copilot's rate limits, you can use the following flags:
348
+ - `--manual`: Enables manual approval for each request, giving you full control over when requests are sent.
349
+ - `--rate-limit <seconds>`: Enforces a minimum time interval between requests. For example, `copilot-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests.
350
+ - `--wait`: Use this with `--rate-limit`. It makes the server wait for the cooldown period to end instead of rejecting the request with an error. This is useful for clients that don't automatically retry on rate limit errors.
351
+ - If you have a GitHub business or enterprise plan account with Copilot, use the `--account-type` flag (e.g., `--account-type business`). See the [official documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/managing-copilot/managing-github-copilot-in-your-organization/managing-access-to-github-copilot-in-your-organization/managing-github-copilot-access-to-your-organizations-network#configuring-copilot-subscription-based-network-routing-for-your-enterprise-or-organization) for more details.
bun.lock ADDED
The diff for this file is too large to render. See raw diff
 
entrypoint.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ if [ "$1" = "--auth" ]; then
3
+ # Run auth command
4
+ exec bun run dist/main.js auth
5
+ else
6
+ # Default command
7
+ exec bun run dist/main.js start -g "$GH_TOKEN" "$@"
8
+ fi
9
+
eslint.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import config from "@echristian/eslint-config"
2
+
3
+ export default config({
4
+ prettier: {
5
+ plugins: ["prettier-plugin-packagejson"],
6
+ },
7
+ })
opencode.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mcp": {
3
+ "playwright": {
4
+ "type": "local",
5
+ "command": [
6
+ "docker",
7
+ "container",
8
+ "run",
9
+ "-i",
10
+ "--rm",
11
+ "--init",
12
+ "--network",
13
+ "host",
14
+ "mcr.microsoft.com/playwright/mcp"
15
+ ],
16
+ "enabled": true
17
+ }
18
+ },
19
+ "$schema": "https://opencode.ai/config.json"
20
+ }
package.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "copilot-api",
3
+ "version": "0.7.0",
4
+ "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
+ "keywords": [
6
+ "proxy",
7
+ "github-copilot",
8
+ "openai-compatible"
9
+ ],
10
+ "homepage": "https://github.com/ericc-ch/copilot-api",
11
+ "bugs": "https://github.com/ericc-ch/copilot-api/issues",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ericc-ch/copilot-api.git"
15
+ },
16
+ "author": "Erick Christian <[email protected]>",
17
+ "type": "module",
18
+ "bin": {
19
+ "copilot-api": "./dist/main.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsdown",
26
+ "dev": "bun run --watch ./src/main.ts",
27
+ "knip": "knip-bun",
28
+ "lint": "eslint --cache",
29
+ "lint:all": "eslint --cache .",
30
+ "prepack": "bun run build",
31
+ "prepare": "simple-git-hooks",
32
+ "release": "bumpp && bun publish --access public",
33
+ "start": "NODE_ENV=production bun run ./src/main.ts",
34
+ "typecheck": "tsc"
35
+ },
36
+ "simple-git-hooks": {
37
+ "pre-commit": "bunx lint-staged"
38
+ },
39
+ "lint-staged": {
40
+ "*": "bun run lint --fix"
41
+ },
42
+ "dependencies": {
43
+ "citty": "^0.1.6",
44
+ "clipboardy": "^5.0.0",
45
+ "consola": "^3.4.2",
46
+ "fetch-event-stream": "^0.1.5",
47
+ "gpt-tokenizer": "^3.0.1",
48
+ "hono": "^4.9.9",
49
+ "proxy-from-env": "^1.1.0",
50
+ "srvx": "^0.8.9",
51
+ "tiny-invariant": "^1.3.3",
52
+ "undici": "^7.16.0",
53
+ "zod": "^4.1.11"
54
+ },
55
+ "devDependencies": {
56
+ "@echristian/eslint-config": "^0.0.54",
57
+ "@types/bun": "^1.2.23",
58
+ "@types/proxy-from-env": "^1.0.4",
59
+ "bumpp": "^10.2.3",
60
+ "eslint": "^9.37.0",
61
+ "knip": "^5.64.1",
62
+ "lint-staged": "^16.2.3",
63
+ "prettier-plugin-packagejson": "^2.5.19",
64
+ "simple-git-hooks": "^2.13.1",
65
+ "tsdown": "^0.15.6",
66
+ "typescript": "^5.9.3"
67
+ }
68
+ }
pages/index.html ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Copilot API Usage Dashboard</title>
7
+
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link
13
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
14
+ rel="stylesheet"
15
+ />
16
+
17
+ <script src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
18
+
19
+ <style>
20
+ /* Gruvbox-themed color palette */
21
+ :root {
22
+ /* Main Color Palette */
23
+ --color-red: #cc241d;
24
+ --color-green: #98971a;
25
+ --color-yellow: #d79921;
26
+ --color-blue: #458588;
27
+ --color-purple: #b16286;
28
+ --color-aqua: #689d6a;
29
+ --color-orange: #d65d0e;
30
+ --color-gray: #a89984;
31
+
32
+ /* Accent/Lighter/Darker Shades of Main Colors */
33
+ --color-red-accent: #fb4934;
34
+ --color-green-accent: #b8bb26;
35
+ --color-yellow-accent: #fabd2f;
36
+ --color-blue-accent: #83a598;
37
+ --color-purple-accent: #d3869b;
38
+ --color-aqua-accent: #8ec07c;
39
+ --color-orange-accent: #fe8019;
40
+ --color-gray-accent: #928374;
41
+
42
+ /* Background Colors */
43
+ --color-bg-darkest: #1d2021; /* bg0_h */
44
+ --color-bg: #282828; /* bg and bg0 */
45
+ --color-bg-light-1: #3c3836; /* bg1 */
46
+ --color-bg-light-2: #504945; /* bg2 */
47
+ --color-bg-light-3: #665c54; /* bg3 */
48
+ --color-bg-light-4: #7c6f64; /* bg4 */
49
+ --color-bg-soft: #32302f; /* bg0_s */
50
+
51
+ /* Foreground Colors */
52
+ --color-fg-darker: #a89984; /* fg4 - duplicate of gray */
53
+ --color-fg-dark: #bdae93; /* fg3 */
54
+ --color-fg-medium: #d5c4a1; /* fg2 */
55
+ --color-fg-light: #ebdbb2; /* fg and fg1 */
56
+ --color-fg-lightest: #fbf1c7; /* fg0 */
57
+ }
58
+
59
+ /* Custom styles using the new palette */
60
+ body {
61
+ font-family: "Inter", sans-serif;
62
+ background-color: var(--color-bg-darkest);
63
+ color: var(--color-fg-light);
64
+ }
65
+
66
+ /* Custom progress bar styles */
67
+ .progress-bar-bg {
68
+ background-color: var(--color-bg-light-1);
69
+ }
70
+ .progress-bar-fg {
71
+ transition: width 0.5s ease-in-out;
72
+ }
73
+
74
+ /* Custom scrollbar for the raw data view */
75
+ .code-block::-webkit-scrollbar {
76
+ width: 8px;
77
+ height: 8px;
78
+ }
79
+ .code-block::-webkit-scrollbar-track {
80
+ background: var(--color-bg);
81
+ }
82
+ .code-block::-webkit-scrollbar-thumb {
83
+ background: var(--color-bg-light-3);
84
+ }
85
+ .code-block::-webkit-scrollbar-thumb:hover {
86
+ background: var(--color-bg-light-4);
87
+ }
88
+
89
+ /* Style for focus rings to use variables */
90
+ .input-focus:focus {
91
+ --tw-ring-color: var(--color-blue);
92
+ border-color: var(--color-blue);
93
+ }
94
+
95
+
96
+ </style>
97
+ </head>
98
+ <body class="antialiased">
99
+ <div id="app" class="min-h-screen p-4 sm:p-6">
100
+ <div class="max-w-7xl mx-auto">
101
+ <!-- Header Section -->
102
+ <header class="mb-6">
103
+ <h1
104
+ class="text-2xl font-bold flex items-center gap-2"
105
+ style="color: var(--color-fg-lightest)"
106
+ >
107
+ <svg
108
+ xmlns="http://www.w3.org/2000/svg"
109
+ width="24"
110
+ height="24"
111
+ viewBox="0 0 24 24"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ stroke-width="2"
115
+ stroke-linecap="round"
116
+ stroke-linejoin="round"
117
+ class="lucide lucide-gauge-circle h-7 w-7"
118
+ style="color: var(--color-aqua-accent)"
119
+ >
120
+ <path d="M15.6 3.3a10 10 0 1 0 5.1 5.1" />
121
+ <path
122
+ d="M12 12a1 1 0 0 0-1-1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-3z"
123
+ />
124
+ <path d="M12 6.8a10 10 0 0 0 -3.2 7.2" />
125
+ </svg>
126
+ <span>Copilot API Usage Dashboard</span>
127
+ </h1>
128
+ <p class="mt-1 text-sm" style="color: var(--color-gray)">
129
+ Should be the same as the one in VSCode
130
+ </p>
131
+ </header>
132
+
133
+ <!-- Form Section -->
134
+ <div
135
+ class="mb-6 p-4 border"
136
+ style="
137
+ background-color: var(--color-bg-soft);
138
+ border-color: var(--color-bg-light-2);
139
+ "
140
+ >
141
+ <form
142
+ id="endpoint-form"
143
+ class="flex flex-col sm:flex-row items-center gap-3"
144
+ >
145
+ <label
146
+ for="endpoint-url"
147
+ class="font-semibold whitespace-nowrap text-sm"
148
+ style="color: var(--color-fg-lightest)"
149
+ >API Endpoint URL</label
150
+ >
151
+ <input
152
+ type="text"
153
+ id="endpoint-url"
154
+ class="w-full px-3 py-1.5 border focus:ring-1 transition input-focus text-sm"
155
+ style="
156
+ background-color: var(--color-bg-darkest);
157
+ border-color: var(--color-bg-light-3);
158
+ color: var(--color-fg-medium);
159
+ "
160
+ placeholder="http://localhost:4141/usage"
161
+ />
162
+ <button
163
+ id="fetch-button"
164
+ type="submit"
165
+ class="w-full sm:w-auto font-bold py-1.5 px-5 transition-colors flex items-center justify-center gap-2 text-sm"
166
+ style="
167
+ background-color: var(--color-blue);
168
+ color: var(--color-bg-darkest);
169
+ "
170
+ >
171
+ <svg
172
+ xmlns="http://www.w3.org/2000/svg"
173
+ width="24"
174
+ height="24"
175
+ viewBox="0 0 24 24"
176
+ fill="none"
177
+ stroke="currentColor"
178
+ stroke-width="2"
179
+ stroke-linecap="round"
180
+ stroke-linejoin="round"
181
+ class="lucide lucide-refresh-cw h-4 w-4"
182
+ >
183
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
184
+ <path d="M21 3v5h-5" />
185
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
186
+ <path d="M3 21v-5h5" />
187
+ </svg>
188
+ <span>Fetch</span>
189
+ </button>
190
+ </form>
191
+ </div>
192
+
193
+ <!-- Content Area for dynamic data -->
194
+ <main id="content-area"></main>
195
+ </div>
196
+ </div>
197
+
198
+ <script>
199
+ document.addEventListener("DOMContentLoaded", () => {
200
+ const endpointForm = document.getElementById("endpoint-form");
201
+ const endpointUrlInput = document.getElementById("endpoint-url");
202
+ const contentArea = document.getElementById("content-area");
203
+ const fetchButton = document.getElementById("fetch-button");
204
+
205
+ // Apply hover effect for fetch button via JS
206
+ fetchButton.addEventListener("mouseenter", () => {
207
+ fetchButton.style.backgroundColor = "var(--color-blue-accent)";
208
+ });
209
+ fetchButton.addEventListener("mouseleave", () => {
210
+ fetchButton.style.backgroundColor = "var(--color-blue)";
211
+ });
212
+
213
+ const DEFAULT_ENDPOINT = "http://localhost:4141/usage";
214
+
215
+ // --- State Management ---
216
+ const state = {
217
+ isLoading: false,
218
+ error: null,
219
+ data: null,
220
+ };
221
+
222
+ // --- Rendering Logic ---
223
+
224
+ /**
225
+ * Safely calls lucide.createIcons() if the library is available.
226
+ */
227
+ function createIcons() {
228
+ if (typeof lucide !== "undefined") {
229
+ lucide.createIcons();
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Renders the entire UI based on the current state.
235
+ */
236
+ function render() {
237
+ if (state.isLoading) {
238
+ contentArea.innerHTML = renderSpinner();
239
+ return;
240
+ }
241
+ if (state.error) {
242
+ contentArea.innerHTML = renderError(state.error);
243
+ } else if (state.data) {
244
+ contentArea.innerHTML = `
245
+ ${renderUsageQuotas(state.data.quota_snapshots)}
246
+ ${renderDetailedData(state.data)}
247
+ `;
248
+ } else {
249
+ contentArea.innerHTML = renderWelcomeMessage();
250
+ }
251
+ // Replace placeholder icons with actual Lucide icons
252
+ createIcons();
253
+ }
254
+
255
+ /**
256
+ * Renders the "Usage Quotas" section with progress bars.
257
+ * @param {object} snapshots - The quota_snapshots object from the API response.
258
+ * @returns {string} HTML string for the usage quotas section.
259
+ */
260
+ function renderUsageQuotas(snapshots) {
261
+ if (!snapshots) return "";
262
+
263
+ const quotaCards = Object.entries(snapshots)
264
+ .map(([key, value]) => {
265
+ return renderQuotaCard(key, value);
266
+ })
267
+ .join("");
268
+
269
+ return `
270
+ <section id="usage-quotas" class="mb-6">
271
+ <h2 class="text-xl font-bold mb-3 flex items-center gap-2" style="color: var(--color-fg-lightest);">
272
+ <i data-lucide="bar-chart-big"></i> Usage Quotas
273
+ </h2>
274
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
275
+ ${quotaCards}
276
+ </div>
277
+ </section>
278
+ `;
279
+ }
280
+
281
+ /**
282
+ * Renders a single quota card.
283
+ * @param {string} title - The name of the quota (e.g., 'chat').
284
+ * @param {object} details - The details object for the quota.
285
+ * @returns {string} HTML string for a single card.
286
+ */
287
+ function renderQuotaCard(title, details) {
288
+ const { entitlement, remaining, percent_remaining, unlimited } =
289
+ details;
290
+
291
+ const percentUsed = unlimited ? 0 : 100 - percent_remaining;
292
+ const used = unlimited
293
+ ? "N/A"
294
+ : (entitlement - remaining).toLocaleString();
295
+
296
+ let progressBarColor = "var(--color-green)";
297
+ if (percentUsed > 75) progressBarColor = "var(--color-yellow)";
298
+ if (percentUsed > 90) progressBarColor = "var(--color-red)";
299
+ if (unlimited) progressBarColor = "var(--color-blue)";
300
+
301
+ return `
302
+ <div class="p-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);">
303
+ <div class="flex justify-between items-center mb-2">
304
+ <h3 class="text-md font-semibold capitalize" style="color: var(--color-fg-lightest);">${title.replace(/_/g, " ")}</h3>
305
+ ${
306
+ unlimited
307
+ ? `<span class="px-2 py-0.5 text-xs font-medium" style="color: var(--color-blue-accent); background-color: var(--color-bg-light-1);">Unlimited</span>`
308
+ : `<span class="text-sm font-mono" style="color: var(--color-fg-medium);">${percentUsed.toFixed(1)}% Used</span>`
309
+ }
310
+ </div>
311
+ <div class="mb-3">
312
+ <div class="w-full progress-bar-bg h-2">
313
+ <div class="progress-bar-fg h-2" style="width: ${unlimited ? 100 : percentUsed}%; background-color: ${progressBarColor};"></div>
314
+ </div>
315
+ </div>
316
+ <div class="flex justify-between text-xs font-mono" style="color: var(--color-fg-dark);">
317
+ <span>${used} / ${unlimited ? "∞" : entitlement.toLocaleString()}</span>
318
+ <span>${unlimited ? "∞" : remaining.toLocaleString()} remaining</span>
319
+ </div>
320
+ </div>
321
+ `;
322
+ }
323
+
324
+ /**
325
+ * Recursively builds a formatted HTML list from a JSON object.
326
+ * @param {object} obj - The object to format.
327
+ * @returns {string} HTML string for the formatted list.
328
+ */
329
+ function formatObject(obj) {
330
+ if (obj === null || typeof obj !== "object") {
331
+ return `<span style="color: var(--color-green-accent);">${JSON.stringify(obj)}</span>`;
332
+ }
333
+
334
+ return (
335
+ '<div class="pl-4">' +
336
+ Object.entries(obj)
337
+ .map(([key, value]) => {
338
+ const formattedKey = key.replace(/_/g, " ");
339
+ let displayValue;
340
+
341
+ if (Array.isArray(value)) {
342
+ displayValue =
343
+ value.length > 0
344
+ ? `<span style='color: var(--color-gray-accent)'>[...${value.length} items]</span>`
345
+ : `<span style='color: var(--color-gray-accent)'>[]</span>`;
346
+ } else if (typeof value === "object" && value !== null) {
347
+ displayValue = formatObject(value);
348
+ } else if (typeof value === "boolean") {
349
+ displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green-accent)" : "var(--color-red-accent)"}">${value}</span>`;
350
+ } else {
351
+ displayValue = `<span style="color: var(--color-blue-accent);">${JSON.stringify(value)}</span>`;
352
+ }
353
+
354
+ return `<div class="mt-1">
355
+ <span class="capitalize font-semibold" style="color: var(--color-fg-medium);">${formattedKey}:</span>
356
+ ${typeof value === "object" && value !== null && !Array.isArray(value) ? displayValue : ` ${displayValue}`}
357
+ </div>`;
358
+ })
359
+ .join("") +
360
+ "</div>"
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Renders the section with the full, formatted API response.
366
+ * @param {object} data - The full API response data.
367
+ * @returns {string} HTML string for the full data section.
368
+ */
369
+ function renderDetailedData(data) {
370
+ const formattedDetails = formatObject(data);
371
+ return `
372
+ <section id="detailed-data">
373
+ <h2 class="text-xl font-bold mb-3 flex items-center gap-2" style="color: var(--color-fg-lightest);">
374
+ <i data-lucide="file-text"></i> Detailed Information
375
+ </h2>
376
+ <div class="border p-4 relative font-mono text-xs" style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2);">
377
+ ${formattedDetails}
378
+ </div>
379
+ </section>
380
+ `;
381
+ }
382
+
383
+ /**
384
+ * Renders a loading spinner.
385
+ * @returns {string} HTML string for the spinner.
386
+ */
387
+ function renderSpinner() {
388
+ return `
389
+ <div class="flex justify-center items-center py-20">
390
+ <div class="animate-spin h-12 w-12 rounded-full border-4 border-transparent border-t-4" style="border-top-color: var(--color-blue);"></div>
391
+ </div>`;
392
+ }
393
+
394
+ /**
395
+ * Renders an error message box.
396
+ * @param {string} message - The error message to display.
397
+ * @returns {string} HTML string for the error message.
398
+ */
399
+ function renderError(message) {
400
+ const container = document.createElement("div");
401
+ container.className = "p-3 border";
402
+ container.style.backgroundColor = "rgba(204, 36, 29, 0.2)";
403
+ container.style.borderColor = "var(--color-red)";
404
+ container.style.color = "var(--color-red-accent)";
405
+ container.setAttribute("role", "alert");
406
+
407
+ container.innerHTML = `
408
+ <div class="flex items-center">
409
+ <i data-lucide="alert-triangle" class="h-5 w-5 mr-3"></i>
410
+ <div>
411
+ <p class="font-bold text-sm">An Error Occurred</p>
412
+ <p class="text-xs">${message}</p>
413
+ </div>
414
+ </div>
415
+ `;
416
+ // Must create icons *after* innerHTML is set
417
+ setTimeout(
418
+ () =>
419
+ lucide.createIcons({
420
+ nodes: [container.querySelector("[data-lucide]")],
421
+ }),
422
+ 0
423
+ );
424
+ return container.outerHTML;
425
+ }
426
+
427
+ /**
428
+ * Renders a welcome message when the page first loads.
429
+ * @returns {string} HTML string for the welcome message.
430
+ */
431
+ function renderWelcomeMessage() {
432
+ return `
433
+ <div class="text-center py-16 px-4 border" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);">
434
+ <i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-gray-accent);"></i>
435
+ <h3 class="mt-2 text-lg font-semibold" style="color: var(--color-fg-lightest);">Welcome!</h3>
436
+ <p class="mt-1 text-sm" style="color: var(--color-gray);">Enter an API endpoint URL and click "Fetch" to see usage data.</p>
437
+ </div>
438
+ `;
439
+ }
440
+
441
+ // --- Data Fetching ---
442
+
443
+ /**
444
+ * Fetches data from the specified API endpoint.
445
+ */
446
+ async function fetchData() {
447
+ const url = endpointUrlInput.value.trim();
448
+ if (!url) {
449
+ state.error = "Endpoint URL cannot be empty.";
450
+ state.isLoading = false;
451
+ render();
452
+ return;
453
+ }
454
+
455
+ state.isLoading = true;
456
+ state.error = null;
457
+ render();
458
+
459
+ try {
460
+ const response = await fetch(url);
461
+ if (!response.ok) {
462
+ throw new Error(
463
+ `Request failed with status ${response.status}: ${response.statusText}`
464
+ );
465
+ }
466
+ const jsonData = await response.json();
467
+ state.data = jsonData;
468
+ } catch (error) {
469
+ console.error("Fetch error:", error);
470
+ state.data = null;
471
+ state.error = error.message;
472
+ } finally {
473
+ state.isLoading = false;
474
+ render();
475
+ }
476
+ }
477
+
478
+ // --- Event Handlers & Initialization ---
479
+
480
+ /**
481
+ * Handles the form submission to trigger a data fetch.
482
+ * @param {Event} event - The form submission event.
483
+ */
484
+ function handleFormSubmit(event) {
485
+ event.preventDefault();
486
+ const url = endpointUrlInput.value.trim();
487
+
488
+ // Update URL query parameter, catching potential security errors in sandboxed environments
489
+ try {
490
+ const currentUrl = new URL(window.location);
491
+ currentUrl.searchParams.set("endpoint", url);
492
+ window.history.pushState({}, "", currentUrl);
493
+ } catch (e) {
494
+ console.warn("Could not update URL: ", e.message);
495
+ }
496
+
497
+ fetchData();
498
+ }
499
+
500
+ /**
501
+ * Initializes the application.
502
+ */
503
+ function init() {
504
+ endpointForm.addEventListener("submit", handleFormSubmit);
505
+
506
+ // Get endpoint from URL param on load
507
+ const urlParams = new URLSearchParams(window.location.search);
508
+ const endpointFromUrl = urlParams.get("endpoint");
509
+
510
+ if (endpointFromUrl) {
511
+ endpointUrlInput.value = endpointFromUrl;
512
+ fetchData();
513
+ } else {
514
+ endpointUrlInput.value = DEFAULT_ENDPOINT;
515
+ render(); // Render initial welcome message
516
+ }
517
+ }
518
+
519
+ // Start the app
520
+ init();
521
+ });
522
+ </script>
523
+
524
+ <footer
525
+ class="text-center py-4 text-xs"
526
+ style="color: var(--color-gray-accent)"
527
+ >
528
+ <p>
529
+ Vibe coded by
530
+ <a
531
+ href="https://gemini.google.com"
532
+ target="_blank"
533
+ rel="noopener noreferrer"
534
+ class="underline transition-colors"
535
+ style="color: var(--color-fg-dark)"
536
+ onmouseover="this.style.color='var(--color-fg-light)'"
537
+ onmouseout="this.style.color='var(--color-fg-dark)'"
538
+ >
539
+ Gemini
540
+ </a>
541
+ - Based on
542
+ <a
543
+ href="https://github.com/uheej0625/copilot-usage-viewer"
544
+ target="_blank"
545
+ rel="noopener noreferrer"
546
+ class="underline transition-colors"
547
+ style="color: var(--color-fg-dark)"
548
+ onmouseover="this.style.color='var(--color-fg-light)'"
549
+ onmouseout="this.style.color='var(--color-fg-dark)'"
550
+ >
551
+ copilot-usage-viewer</a
552
+ >
553
+ </p>
554
+ </footer>
555
+ </body>
556
+ </html>
src/auth.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { defineCommand } from "citty"
4
+ import consola from "consola"
5
+
6
+ import { PATHS, ensurePaths } from "./lib/paths"
7
+ import { state } from "./lib/state"
8
+ import { setupGitHubToken } from "./lib/token"
9
+
10
+ interface RunAuthOptions {
11
+ verbose: boolean
12
+ showToken: boolean
13
+ }
14
+
15
+ export async function runAuth(options: RunAuthOptions): Promise<void> {
16
+ if (options.verbose) {
17
+ consola.level = 5
18
+ consola.info("Verbose logging enabled")
19
+ }
20
+
21
+ state.showToken = options.showToken
22
+
23
+ await ensurePaths()
24
+ await setupGitHubToken({ force: true })
25
+ consola.success("GitHub token written to", PATHS.GITHUB_TOKEN_PATH)
26
+ }
27
+
28
+ export const auth = defineCommand({
29
+ meta: {
30
+ name: "auth",
31
+ description: "Run GitHub auth flow without running the server",
32
+ },
33
+ args: {
34
+ verbose: {
35
+ alias: "v",
36
+ type: "boolean",
37
+ default: false,
38
+ description: "Enable verbose logging",
39
+ },
40
+ "show-token": {
41
+ type: "boolean",
42
+ default: false,
43
+ description: "Show GitHub token on auth",
44
+ },
45
+ },
46
+ run({ args }) {
47
+ return runAuth({
48
+ verbose: args.verbose,
49
+ showToken: args["show-token"],
50
+ })
51
+ },
52
+ })
src/check-usage.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineCommand } from "citty"
2
+ import consola from "consola"
3
+
4
+ import { ensurePaths } from "./lib/paths"
5
+ import { setupGitHubToken } from "./lib/token"
6
+ import {
7
+ getCopilotUsage,
8
+ type QuotaDetail,
9
+ } from "./services/github/get-copilot-usage"
10
+
11
+ export const checkUsage = defineCommand({
12
+ meta: {
13
+ name: "check-usage",
14
+ description: "Show current GitHub Copilot usage/quota information",
15
+ },
16
+ async run() {
17
+ await ensurePaths()
18
+ await setupGitHubToken()
19
+ try {
20
+ const usage = await getCopilotUsage()
21
+ const premium = usage.quota_snapshots.premium_interactions
22
+ const premiumTotal = premium.entitlement
23
+ const premiumUsed = premiumTotal - premium.remaining
24
+ const premiumPercentUsed =
25
+ premiumTotal > 0 ? (premiumUsed / premiumTotal) * 100 : 0
26
+ const premiumPercentRemaining = premium.percent_remaining
27
+
28
+ // Helper to summarize a quota snapshot
29
+ function summarizeQuota(name: string, snap: QuotaDetail | undefined) {
30
+ if (!snap) return `${name}: N/A`
31
+ const total = snap.entitlement
32
+ const used = total - snap.remaining
33
+ const percentUsed = total > 0 ? (used / total) * 100 : 0
34
+ const percentRemaining = snap.percent_remaining
35
+ return `${name}: ${used}/${total} used (${percentUsed.toFixed(1)}% used, ${percentRemaining.toFixed(1)}% remaining)`
36
+ }
37
+
38
+ const premiumLine = `Premium: ${premiumUsed}/${premiumTotal} used (${premiumPercentUsed.toFixed(1)}% used, ${premiumPercentRemaining.toFixed(1)}% remaining)`
39
+ const chatLine = summarizeQuota("Chat", usage.quota_snapshots.chat)
40
+ const completionsLine = summarizeQuota(
41
+ "Completions",
42
+ usage.quota_snapshots.completions,
43
+ )
44
+
45
+ consola.box(
46
+ `Copilot Usage (plan: ${usage.copilot_plan})\n`
47
+ + `Quota resets: ${usage.quota_reset_date}\n`
48
+ + `\nQuotas:\n`
49
+ + ` ${premiumLine}\n`
50
+ + ` ${chatLine}\n`
51
+ + ` ${completionsLine}`,
52
+ )
53
+ } catch (err) {
54
+ consola.error("Failed to fetch Copilot usage:", err)
55
+ process.exit(1)
56
+ }
57
+ },
58
+ })
src/debug.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { defineCommand } from "citty"
4
+ import consola from "consola"
5
+ import fs from "node:fs/promises"
6
+ import os from "node:os"
7
+
8
+ import { PATHS } from "./lib/paths"
9
+
10
+ interface DebugInfo {
11
+ version: string
12
+ runtime: {
13
+ name: string
14
+ version: string
15
+ platform: string
16
+ arch: string
17
+ }
18
+ paths: {
19
+ APP_DIR: string
20
+ GITHUB_TOKEN_PATH: string
21
+ }
22
+ tokenExists: boolean
23
+ }
24
+
25
+ interface RunDebugOptions {
26
+ json: boolean
27
+ }
28
+
29
+ async function getPackageVersion(): Promise<string> {
30
+ try {
31
+ const packageJsonPath = new URL("../package.json", import.meta.url).pathname
32
+ // @ts-expect-error https://github.com/sindresorhus/eslint-plugin-unicorn/blob/v59.0.1/docs/rules/prefer-json-parse-buffer.md
33
+ // JSON.parse() can actually parse buffers
34
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath)) as {
35
+ version: string
36
+ }
37
+ return packageJson.version
38
+ } catch {
39
+ return "unknown"
40
+ }
41
+ }
42
+
43
+ function getRuntimeInfo() {
44
+ const isBun = typeof Bun !== "undefined"
45
+
46
+ return {
47
+ name: isBun ? "bun" : "node",
48
+ version: isBun ? Bun.version : process.version.slice(1),
49
+ platform: os.platform(),
50
+ arch: os.arch(),
51
+ }
52
+ }
53
+
54
+ async function checkTokenExists(): Promise<boolean> {
55
+ try {
56
+ const stats = await fs.stat(PATHS.GITHUB_TOKEN_PATH)
57
+ if (!stats.isFile()) return false
58
+
59
+ const content = await fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
60
+ return content.trim().length > 0
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ async function getDebugInfo(): Promise<DebugInfo> {
67
+ const [version, tokenExists] = await Promise.all([
68
+ getPackageVersion(),
69
+ checkTokenExists(),
70
+ ])
71
+
72
+ return {
73
+ version,
74
+ runtime: getRuntimeInfo(),
75
+ paths: {
76
+ APP_DIR: PATHS.APP_DIR,
77
+ GITHUB_TOKEN_PATH: PATHS.GITHUB_TOKEN_PATH,
78
+ },
79
+ tokenExists,
80
+ }
81
+ }
82
+
83
+ function printDebugInfoPlain(info: DebugInfo): void {
84
+ consola.info(`copilot-api debug
85
+
86
+ Version: ${info.version}
87
+ Runtime: ${info.runtime.name} ${info.runtime.version} (${info.runtime.platform} ${info.runtime.arch})
88
+
89
+ Paths:
90
+ - APP_DIR: ${info.paths.APP_DIR}
91
+ - GITHUB_TOKEN_PATH: ${info.paths.GITHUB_TOKEN_PATH}
92
+
93
+ Token exists: ${info.tokenExists ? "Yes" : "No"}`)
94
+ }
95
+
96
+ function printDebugInfoJson(info: DebugInfo): void {
97
+ console.log(JSON.stringify(info, null, 2))
98
+ }
99
+
100
+ export async function runDebug(options: RunDebugOptions): Promise<void> {
101
+ const debugInfo = await getDebugInfo()
102
+
103
+ if (options.json) {
104
+ printDebugInfoJson(debugInfo)
105
+ } else {
106
+ printDebugInfoPlain(debugInfo)
107
+ }
108
+ }
109
+
110
+ export const debug = defineCommand({
111
+ meta: {
112
+ name: "debug",
113
+ description: "Print debug information about the application",
114
+ },
115
+ args: {
116
+ json: {
117
+ type: "boolean",
118
+ default: false,
119
+ description: "Output debug information as JSON",
120
+ },
121
+ },
122
+ run({ args }) {
123
+ return runDebug({
124
+ json: args.json,
125
+ })
126
+ },
127
+ })
src/lib/api-config.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "node:crypto"
2
+
3
+ import type { State } from "./state"
4
+
5
+ export const standardHeaders = () => ({
6
+ "content-type": "application/json",
7
+ accept: "application/json",
8
+ })
9
+
10
+ const COPILOT_VERSION = "0.26.7"
11
+ const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`
12
+ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`
13
+
14
+ const API_VERSION = "2025-04-01"
15
+
16
+ export const copilotBaseUrl = (state: State) =>
17
+ state.accountType === "individual" ?
18
+ "https://api.githubcopilot.com"
19
+ : `https://api.${state.accountType}.githubcopilot.com`
20
+ export const copilotHeaders = (state: State, vision: boolean = false) => {
21
+ const headers: Record<string, string> = {
22
+ Authorization: `Bearer ${state.copilotToken}`,
23
+ "content-type": standardHeaders()["content-type"],
24
+ "copilot-integration-id": "vscode-chat",
25
+ "editor-version": `vscode/${state.vsCodeVersion}`,
26
+ "editor-plugin-version": EDITOR_PLUGIN_VERSION,
27
+ "user-agent": USER_AGENT,
28
+ "openai-intent": "conversation-panel",
29
+ "x-github-api-version": API_VERSION,
30
+ "x-request-id": randomUUID(),
31
+ "x-vscode-user-agent-library-version": "electron-fetch",
32
+ }
33
+
34
+ if (vision) headers["copilot-vision-request"] = "true"
35
+
36
+ return headers
37
+ }
38
+
39
+ export const GITHUB_API_BASE_URL = "https://api.github.com"
40
+ export const githubHeaders = (state: State) => ({
41
+ ...standardHeaders(),
42
+ authorization: `token ${state.githubToken}`,
43
+ "editor-version": `vscode/${state.vsCodeVersion}`,
44
+ "editor-plugin-version": EDITOR_PLUGIN_VERSION,
45
+ "user-agent": USER_AGENT,
46
+ "x-github-api-version": API_VERSION,
47
+ "x-vscode-user-agent-library-version": "electron-fetch",
48
+ })
49
+
50
+ export const GITHUB_BASE_URL = "https://github.com"
51
+ export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
52
+ export const GITHUB_APP_SCOPES = ["read:user"].join(" ")
src/lib/approval.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+
3
+ import { HTTPError } from "./error"
4
+
5
+ export const awaitApproval = async () => {
6
+ const response = await consola.prompt(`Accept incoming request?`, {
7
+ type: "confirm",
8
+ })
9
+
10
+ if (!response)
11
+ throw new HTTPError(
12
+ "Request rejected",
13
+ Response.json({ message: "Request rejected" }, { status: 403 }),
14
+ )
15
+ }
src/lib/error.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context } from "hono"
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status"
3
+
4
+ import consola from "consola"
5
+
6
+ export class HTTPError extends Error {
7
+ response: Response
8
+
9
+ constructor(message: string, response: Response) {
10
+ super(message)
11
+ this.response = response
12
+ }
13
+ }
14
+
15
+ export async function forwardError(c: Context, error: unknown) {
16
+ consola.error("Error occurred:", error)
17
+
18
+ if (error instanceof HTTPError) {
19
+ const errorText = await error.response.text()
20
+ let errorJson: unknown
21
+ try {
22
+ errorJson = JSON.parse(errorText)
23
+ } catch {
24
+ errorJson = errorText
25
+ }
26
+ consola.error("HTTP error:", errorJson)
27
+ return c.json(
28
+ {
29
+ error: {
30
+ message: errorText,
31
+ type: "error",
32
+ },
33
+ },
34
+ error.response.status as ContentfulStatusCode,
35
+ )
36
+ }
37
+
38
+ return c.json(
39
+ {
40
+ error: {
41
+ message: (error as Error).message,
42
+ type: "error",
43
+ },
44
+ },
45
+ 500,
46
+ )
47
+ }
src/lib/paths.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")
6
+
7
+ const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token")
8
+
9
+ export const PATHS = {
10
+ APP_DIR,
11
+ GITHUB_TOKEN_PATH,
12
+ }
13
+
14
+ export async function ensurePaths(): Promise<void> {
15
+ await fs.mkdir(PATHS.APP_DIR, { recursive: true })
16
+ await ensureFile(PATHS.GITHUB_TOKEN_PATH)
17
+ }
18
+
19
+ async function ensureFile(filePath: string): Promise<void> {
20
+ try {
21
+ await fs.access(filePath, fs.constants.W_OK)
22
+ } catch {
23
+ await fs.writeFile(filePath, "")
24
+ await fs.chmod(filePath, 0o600)
25
+ }
26
+ }
src/lib/proxy.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+ import { getProxyForUrl } from "proxy-from-env"
3
+ import { Agent, ProxyAgent, setGlobalDispatcher, type Dispatcher } from "undici"
4
+
5
+ export function initProxyFromEnv(): void {
6
+ if (typeof Bun !== "undefined") return
7
+
8
+ try {
9
+ const direct = new Agent()
10
+ const proxies = new Map<string, ProxyAgent>()
11
+
12
+ // We only need a minimal dispatcher that implements `dispatch` at runtime.
13
+ // Typing the object as `Dispatcher` forces TypeScript to require many
14
+ // additional methods. Instead, keep a plain object and cast when passing
15
+ // to `setGlobalDispatcher`.
16
+ const dispatcher = {
17
+ dispatch(
18
+ options: Dispatcher.DispatchOptions,
19
+ handler: Dispatcher.DispatchHandler,
20
+ ) {
21
+ try {
22
+ const origin =
23
+ typeof options.origin === "string" ?
24
+ new URL(options.origin)
25
+ : (options.origin as URL)
26
+ const get = getProxyForUrl as unknown as (
27
+ u: string,
28
+ ) => string | undefined
29
+ const raw = get(origin.toString())
30
+ const proxyUrl = raw && raw.length > 0 ? raw : undefined
31
+ if (!proxyUrl) {
32
+ consola.debug(`HTTP proxy bypass: ${origin.hostname}`)
33
+ return (direct as unknown as Dispatcher).dispatch(options, handler)
34
+ }
35
+ let agent = proxies.get(proxyUrl)
36
+ if (!agent) {
37
+ agent = new ProxyAgent(proxyUrl)
38
+ proxies.set(proxyUrl, agent)
39
+ }
40
+ let label = proxyUrl
41
+ try {
42
+ const u = new URL(proxyUrl)
43
+ label = `${u.protocol}//${u.host}`
44
+ } catch {
45
+ /* noop */
46
+ }
47
+ consola.debug(`HTTP proxy route: ${origin.hostname} via ${label}`)
48
+ return (agent as unknown as Dispatcher).dispatch(options, handler)
49
+ } catch {
50
+ return (direct as unknown as Dispatcher).dispatch(options, handler)
51
+ }
52
+ },
53
+ close() {
54
+ return direct.close()
55
+ },
56
+ destroy() {
57
+ return direct.destroy()
58
+ },
59
+ }
60
+
61
+ setGlobalDispatcher(dispatcher as unknown as Dispatcher)
62
+ consola.debug("HTTP proxy configured from environment (per-URL)")
63
+ } catch (err) {
64
+ consola.debug("Proxy setup skipped:", err)
65
+ }
66
+ }
src/lib/rate-limit.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+
3
+ import type { State } from "./state"
4
+
5
+ import { HTTPError } from "./error"
6
+ import { sleep } from "./utils"
7
+
8
+ export async function checkRateLimit(state: State) {
9
+ if (state.rateLimitSeconds === undefined) return
10
+
11
+ const now = Date.now()
12
+
13
+ if (!state.lastRequestTimestamp) {
14
+ state.lastRequestTimestamp = now
15
+ return
16
+ }
17
+
18
+ const elapsedSeconds = (now - state.lastRequestTimestamp) / 1000
19
+
20
+ if (elapsedSeconds > state.rateLimitSeconds) {
21
+ state.lastRequestTimestamp = now
22
+ return
23
+ }
24
+
25
+ const waitTimeSeconds = Math.ceil(state.rateLimitSeconds - elapsedSeconds)
26
+
27
+ if (!state.rateLimitWait) {
28
+ consola.warn(
29
+ `Rate limit exceeded. Need to wait ${waitTimeSeconds} more seconds.`,
30
+ )
31
+ throw new HTTPError(
32
+ "Rate limit exceeded",
33
+ Response.json({ message: "Rate limit exceeded" }, { status: 429 }),
34
+ )
35
+ }
36
+
37
+ const waitTimeMs = waitTimeSeconds * 1000
38
+ consola.warn(
39
+ `Rate limit reached. Waiting ${waitTimeSeconds} seconds before proceeding...`,
40
+ )
41
+ await sleep(waitTimeMs)
42
+ // eslint-disable-next-line require-atomic-updates
43
+ state.lastRequestTimestamp = now
44
+ consola.info("Rate limit wait completed, proceeding with request")
45
+ return
46
+ }
src/lib/shell.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { execSync } from "node:child_process"
2
+ import process from "node:process"
3
+
4
+ type ShellName = "bash" | "zsh" | "fish" | "powershell" | "cmd" | "sh"
5
+ type EnvVars = Record<string, string | undefined>
6
+
7
+ function getShell(): ShellName {
8
+ const { platform, ppid, env } = process
9
+
10
+ if (platform === "win32") {
11
+ try {
12
+ const command = `wmic process get ParentProcessId,Name | findstr "${ppid}"`
13
+ const parentProcess = execSync(command, { stdio: "pipe" }).toString()
14
+
15
+ if (parentProcess.toLowerCase().includes("powershell.exe")) {
16
+ return "powershell"
17
+ }
18
+ } catch {
19
+ return "cmd"
20
+ }
21
+
22
+ return "cmd"
23
+ } else {
24
+ const shellPath = env.SHELL
25
+ if (shellPath) {
26
+ if (shellPath.endsWith("zsh")) return "zsh"
27
+ if (shellPath.endsWith("fish")) return "fish"
28
+ if (shellPath.endsWith("bash")) return "bash"
29
+ }
30
+
31
+ return "sh"
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Generates a copy-pasteable script to set multiple environment variables
37
+ * and run a subsequent command.
38
+ * @param {EnvVars} envVars - An object of environment variables to set.
39
+ * @param {string} commandToRun - The command to run after setting the variables.
40
+ * @returns {string} The formatted script string.
41
+ */
42
+ export function generateEnvScript(
43
+ envVars: EnvVars,
44
+ commandToRun: string = "",
45
+ ): string {
46
+ const shell = getShell()
47
+ const filteredEnvVars = Object.entries(envVars).filter(
48
+ ([, value]) => value !== undefined,
49
+ ) as Array<[string, string]>
50
+
51
+ let commandBlock: string
52
+
53
+ switch (shell) {
54
+ case "powershell": {
55
+ commandBlock = filteredEnvVars
56
+ .map(([key, value]) => `$env:${key} = ${value}`)
57
+ .join("; ")
58
+ break
59
+ }
60
+ case "cmd": {
61
+ commandBlock = filteredEnvVars
62
+ .map(([key, value]) => `set ${key}=${value}`)
63
+ .join(" & ")
64
+ break
65
+ }
66
+ case "fish": {
67
+ commandBlock = filteredEnvVars
68
+ .map(([key, value]) => `set -gx ${key} ${value}`)
69
+ .join("; ")
70
+ break
71
+ }
72
+ default: {
73
+ // bash, zsh, sh
74
+ const assignments = filteredEnvVars
75
+ .map(([key, value]) => `${key}=${value}`)
76
+ .join(" ")
77
+ commandBlock = filteredEnvVars.length > 0 ? `export ${assignments}` : ""
78
+ break
79
+ }
80
+ }
81
+
82
+ if (commandBlock && commandToRun) {
83
+ const separator = shell === "cmd" ? " & " : " && "
84
+ return `${commandBlock}${separator}${commandToRun}`
85
+ }
86
+
87
+ return commandBlock || commandToRun
88
+ }
src/lib/state.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ModelsResponse } from "~/services/copilot/get-models"
2
+
3
+ export interface State {
4
+ githubToken?: string
5
+ copilotToken?: string
6
+
7
+ accountType: string
8
+ models?: ModelsResponse
9
+ vsCodeVersion?: string
10
+
11
+ manualApprove: boolean
12
+ rateLimitWait: boolean
13
+ showToken: boolean
14
+
15
+ // Rate limiting configuration
16
+ rateLimitSeconds?: number
17
+ lastRequestTimestamp?: number
18
+ }
19
+
20
+ export const state: State = {
21
+ accountType: "individual",
22
+ manualApprove: false,
23
+ rateLimitWait: false,
24
+ showToken: false,
25
+ }
src/lib/token.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+ import fs from "node:fs/promises"
3
+
4
+ import { PATHS } from "~/lib/paths"
5
+ import { getCopilotToken } from "~/services/github/get-copilot-token"
6
+ import { getDeviceCode } from "~/services/github/get-device-code"
7
+ import { getGitHubUser } from "~/services/github/get-user"
8
+ import { pollAccessToken } from "~/services/github/poll-access-token"
9
+
10
+ import { HTTPError } from "./error"
11
+ import { state } from "./state"
12
+
13
+ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
14
+
15
+ const writeGithubToken = (token: string) =>
16
+ fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)
17
+
18
+ export const setupCopilotToken = async () => {
19
+ const { token, refresh_in } = await getCopilotToken()
20
+ state.copilotToken = token
21
+
22
+ // Display the Copilot token to the screen
23
+ consola.debug("GitHub Copilot Token fetched successfully!")
24
+ if (state.showToken) {
25
+ consola.info("Copilot token:", token)
26
+ }
27
+
28
+ const refreshInterval = (refresh_in - 60) * 1000
29
+ setInterval(async () => {
30
+ consola.debug("Refreshing Copilot token")
31
+ try {
32
+ const { token } = await getCopilotToken()
33
+ state.copilotToken = token
34
+ consola.debug("Copilot token refreshed")
35
+ if (state.showToken) {
36
+ consola.info("Refreshed Copilot token:", token)
37
+ }
38
+ } catch (error) {
39
+ consola.error("Failed to refresh Copilot token:", error)
40
+ throw error
41
+ }
42
+ }, refreshInterval)
43
+ }
44
+
45
+ interface SetupGitHubTokenOptions {
46
+ force?: boolean
47
+ }
48
+
49
+ export async function setupGitHubToken(
50
+ options?: SetupGitHubTokenOptions,
51
+ ): Promise<void> {
52
+ try {
53
+ const githubToken = await readGithubToken()
54
+
55
+ if (githubToken && !options?.force) {
56
+ state.githubToken = githubToken
57
+ if (state.showToken) {
58
+ consola.info("GitHub token:", githubToken)
59
+ }
60
+ await logUser()
61
+
62
+ return
63
+ }
64
+
65
+ consola.info("Not logged in, getting new access token")
66
+ const response = await getDeviceCode()
67
+ consola.debug("Device code response:", response)
68
+
69
+ consola.info(
70
+ `Please enter the code "${response.user_code}" in ${response.verification_uri}`,
71
+ )
72
+
73
+ const token = await pollAccessToken(response)
74
+ await writeGithubToken(token)
75
+ state.githubToken = token
76
+
77
+ if (state.showToken) {
78
+ consola.info("GitHub token:", token)
79
+ }
80
+ await logUser()
81
+ } catch (error) {
82
+ if (error instanceof HTTPError) {
83
+ consola.error("Failed to get GitHub token:", await error.response.json())
84
+ throw error
85
+ }
86
+
87
+ consola.error("Failed to get GitHub token:", error)
88
+ throw error
89
+ }
90
+ }
91
+
92
+ async function logUser() {
93
+ const user = await getGitHubUser()
94
+ consola.info(`Logged in as ${user.login}`)
95
+ }
src/lib/tokenizer.ts ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ChatCompletionsPayload,
3
+ ContentPart,
4
+ Message,
5
+ Tool,
6
+ ToolCall,
7
+ } from "~/services/copilot/create-chat-completions"
8
+ import type { Model } from "~/services/copilot/get-models"
9
+
10
+ // Encoder type mapping
11
+ const ENCODING_MAP = {
12
+ o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
13
+ cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
14
+ p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
15
+ p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
16
+ r50k_base: () => import("gpt-tokenizer/encoding/r50k_base"),
17
+ } as const
18
+
19
+ type SupportedEncoding = keyof typeof ENCODING_MAP
20
+
21
+ // Define encoder interface
22
+ interface Encoder {
23
+ encode: (text: string) => Array<number>
24
+ }
25
+
26
+ // Cache loaded encoders to avoid repeated imports
27
+ const encodingCache = new Map<string, Encoder>()
28
+
29
+ /**
30
+ * Calculate tokens for tool calls
31
+ */
32
+ const calculateToolCallsTokens = (
33
+ toolCalls: Array<ToolCall>,
34
+ encoder: Encoder,
35
+ constants: ReturnType<typeof getModelConstants>,
36
+ ): number => {
37
+ let tokens = 0
38
+ for (const toolCall of toolCalls) {
39
+ tokens += constants.funcInit
40
+ tokens += encoder.encode(JSON.stringify(toolCall)).length
41
+ }
42
+ tokens += constants.funcEnd
43
+ return tokens
44
+ }
45
+
46
+ /**
47
+ * Calculate tokens for content parts
48
+ */
49
+ const calculateContentPartsTokens = (
50
+ contentParts: Array<ContentPart>,
51
+ encoder: Encoder,
52
+ ): number => {
53
+ let tokens = 0
54
+ for (const part of contentParts) {
55
+ if (part.type === "image_url") {
56
+ tokens += encoder.encode(part.image_url.url).length + 85
57
+ } else if (part.text) {
58
+ tokens += encoder.encode(part.text).length
59
+ }
60
+ }
61
+ return tokens
62
+ }
63
+
64
+ /**
65
+ * Calculate tokens for a single message
66
+ */
67
+ const calculateMessageTokens = (
68
+ message: Message,
69
+ encoder: Encoder,
70
+ constants: ReturnType<typeof getModelConstants>,
71
+ ): number => {
72
+ const tokensPerMessage = 3
73
+ const tokensPerName = 1
74
+ let tokens = tokensPerMessage
75
+ for (const [key, value] of Object.entries(message)) {
76
+ if (typeof value === "string") {
77
+ tokens += encoder.encode(value).length
78
+ }
79
+ if (key === "name") {
80
+ tokens += tokensPerName
81
+ }
82
+ if (key === "tool_calls") {
83
+ tokens += calculateToolCallsTokens(
84
+ value as Array<ToolCall>,
85
+ encoder,
86
+ constants,
87
+ )
88
+ }
89
+ if (key === "content" && Array.isArray(value)) {
90
+ tokens += calculateContentPartsTokens(
91
+ value as Array<ContentPart>,
92
+ encoder,
93
+ )
94
+ }
95
+ }
96
+ return tokens
97
+ }
98
+
99
+ /**
100
+ * Calculate tokens using custom algorithm
101
+ */
102
+ const calculateTokens = (
103
+ messages: Array<Message>,
104
+ encoder: Encoder,
105
+ constants: ReturnType<typeof getModelConstants>,
106
+ ): number => {
107
+ if (messages.length === 0) {
108
+ return 0
109
+ }
110
+ let numTokens = 0
111
+ for (const message of messages) {
112
+ numTokens += calculateMessageTokens(message, encoder, constants)
113
+ }
114
+ // every reply is primed with <|start|>assistant<|message|>
115
+ numTokens += 3
116
+ return numTokens
117
+ }
118
+
119
+ /**
120
+ * Get the corresponding encoder module based on encoding type
121
+ */
122
+ const getEncodeChatFunction = async (encoding: string): Promise<Encoder> => {
123
+ if (encodingCache.has(encoding)) {
124
+ const cached = encodingCache.get(encoding)
125
+ if (cached) {
126
+ return cached
127
+ }
128
+ }
129
+
130
+ const supportedEncoding = encoding as SupportedEncoding
131
+ if (!(supportedEncoding in ENCODING_MAP)) {
132
+ const fallbackModule = (await ENCODING_MAP.o200k_base()) as Encoder
133
+ encodingCache.set(encoding, fallbackModule)
134
+ return fallbackModule
135
+ }
136
+
137
+ const encodingModule = (await ENCODING_MAP[supportedEncoding]()) as Encoder
138
+ encodingCache.set(encoding, encodingModule)
139
+ return encodingModule
140
+ }
141
+
142
+ /**
143
+ * Get tokenizer type from model information
144
+ */
145
+ export const getTokenizerFromModel = (model: Model): string => {
146
+ return model.capabilities.tokenizer || "o200k_base"
147
+ }
148
+
149
+ /**
150
+ * Get model-specific constants for token calculation
151
+ */
152
+ const getModelConstants = (model: Model) => {
153
+ return model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ?
154
+ {
155
+ funcInit: 10,
156
+ propInit: 3,
157
+ propKey: 3,
158
+ enumInit: -3,
159
+ enumItem: 3,
160
+ funcEnd: 12,
161
+ }
162
+ : {
163
+ funcInit: 7,
164
+ propInit: 3,
165
+ propKey: 3,
166
+ enumInit: -3,
167
+ enumItem: 3,
168
+ funcEnd: 12,
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Calculate tokens for a single parameter
174
+ */
175
+ const calculateParameterTokens = (
176
+ key: string,
177
+ prop: unknown,
178
+ context: {
179
+ encoder: Encoder
180
+ constants: ReturnType<typeof getModelConstants>
181
+ },
182
+ ): number => {
183
+ const { encoder, constants } = context
184
+ let tokens = constants.propKey
185
+
186
+ // Early return if prop is not an object
187
+ if (typeof prop !== "object" || prop === null) {
188
+ return tokens
189
+ }
190
+
191
+ // Type assertion for parameter properties
192
+ const param = prop as {
193
+ type?: string
194
+ description?: string
195
+ enum?: Array<unknown>
196
+ [key: string]: unknown
197
+ }
198
+
199
+ const paramName = key
200
+ const paramType = param.type || "string"
201
+ let paramDesc = param.description || ""
202
+
203
+ // Handle enum values
204
+ if (param.enum && Array.isArray(param.enum)) {
205
+ tokens += constants.enumInit
206
+ for (const item of param.enum) {
207
+ tokens += constants.enumItem
208
+ tokens += encoder.encode(String(item)).length
209
+ }
210
+ }
211
+
212
+ // Clean up description
213
+ if (paramDesc.endsWith(".")) {
214
+ paramDesc = paramDesc.slice(0, -1)
215
+ }
216
+
217
+ // Encode the main parameter line
218
+ const line = `${paramName}:${paramType}:${paramDesc}`
219
+ tokens += encoder.encode(line).length
220
+
221
+ // Handle additional properties (excluding standard ones)
222
+ const excludedKeys = new Set(["type", "description", "enum"])
223
+ for (const propertyName of Object.keys(param)) {
224
+ if (!excludedKeys.has(propertyName)) {
225
+ const propertyValue = param[propertyName]
226
+ const propertyText =
227
+ typeof propertyValue === "string" ? propertyValue : (
228
+ JSON.stringify(propertyValue)
229
+ )
230
+ tokens += encoder.encode(`${propertyName}:${propertyText}`).length
231
+ }
232
+ }
233
+
234
+ return tokens
235
+ }
236
+
237
+ /**
238
+ * Calculate tokens for function parameters
239
+ */
240
+ const calculateParametersTokens = (
241
+ parameters: unknown,
242
+ encoder: Encoder,
243
+ constants: ReturnType<typeof getModelConstants>,
244
+ ): number => {
245
+ if (!parameters || typeof parameters !== "object") {
246
+ return 0
247
+ }
248
+
249
+ const params = parameters as Record<string, unknown>
250
+ let tokens = 0
251
+
252
+ for (const [key, value] of Object.entries(params)) {
253
+ if (key === "properties") {
254
+ const properties = value as Record<string, unknown>
255
+ if (Object.keys(properties).length > 0) {
256
+ tokens += constants.propInit
257
+ for (const propKey of Object.keys(properties)) {
258
+ tokens += calculateParameterTokens(propKey, properties[propKey], {
259
+ encoder,
260
+ constants,
261
+ })
262
+ }
263
+ }
264
+ } else {
265
+ const paramText =
266
+ typeof value === "string" ? value : JSON.stringify(value)
267
+ tokens += encoder.encode(`${key}:${paramText}`).length
268
+ }
269
+ }
270
+
271
+ return tokens
272
+ }
273
+
274
+ /**
275
+ * Calculate tokens for a single tool
276
+ */
277
+ const calculateToolTokens = (
278
+ tool: Tool,
279
+ encoder: Encoder,
280
+ constants: ReturnType<typeof getModelConstants>,
281
+ ): number => {
282
+ let tokens = constants.funcInit
283
+ const func = tool.function
284
+ const fName = func.name
285
+ let fDesc = func.description || ""
286
+ if (fDesc.endsWith(".")) {
287
+ fDesc = fDesc.slice(0, -1)
288
+ }
289
+ const line = fName + ":" + fDesc
290
+ tokens += encoder.encode(line).length
291
+ if (
292
+ typeof func.parameters === "object" // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
293
+ && func.parameters !== null
294
+ ) {
295
+ tokens += calculateParametersTokens(func.parameters, encoder, constants)
296
+ }
297
+ return tokens
298
+ }
299
+
300
+ /**
301
+ * Calculate token count for tools based on model
302
+ */
303
+ export const numTokensForTools = (
304
+ tools: Array<Tool>,
305
+ encoder: Encoder,
306
+ constants: ReturnType<typeof getModelConstants>,
307
+ ): number => {
308
+ let funcTokenCount = 0
309
+ for (const tool of tools) {
310
+ funcTokenCount += calculateToolTokens(tool, encoder, constants)
311
+ }
312
+ funcTokenCount += constants.funcEnd
313
+ return funcTokenCount
314
+ }
315
+
316
+ /**
317
+ * Calculate the token count of messages, supporting multiple GPT encoders
318
+ */
319
+ export const getTokenCount = async (
320
+ payload: ChatCompletionsPayload,
321
+ model: Model,
322
+ ): Promise<{ input: number; output: number }> => {
323
+ // Get tokenizer string
324
+ const tokenizer = getTokenizerFromModel(model)
325
+
326
+ // Get corresponding encoder module
327
+ const encoder = await getEncodeChatFunction(tokenizer)
328
+
329
+ const simplifiedMessages = payload.messages
330
+ const inputMessages = simplifiedMessages.filter(
331
+ (msg) => msg.role !== "assistant",
332
+ )
333
+ const outputMessages = simplifiedMessages.filter(
334
+ (msg) => msg.role === "assistant",
335
+ )
336
+
337
+ const constants = getModelConstants(model)
338
+ let inputTokens = calculateTokens(inputMessages, encoder, constants)
339
+ if (payload.tools && payload.tools.length > 0) {
340
+ inputTokens += numTokensForTools(payload.tools, encoder, constants)
341
+ }
342
+ const outputTokens = calculateTokens(outputMessages, encoder, constants)
343
+
344
+ return {
345
+ input: inputTokens,
346
+ output: outputTokens,
347
+ }
348
+ }
src/lib/utils.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+
3
+ import { getModels } from "~/services/copilot/get-models"
4
+ import { getVSCodeVersion } from "~/services/get-vscode-version"
5
+
6
+ import { state } from "./state"
7
+
8
+ export const sleep = (ms: number) =>
9
+ new Promise((resolve) => {
10
+ setTimeout(resolve, ms)
11
+ })
12
+
13
+ export const isNullish = (value: unknown): value is null | undefined =>
14
+ value === null || value === undefined
15
+
16
+ export async function cacheModels(): Promise<void> {
17
+ const models = await getModels()
18
+ state.models = models
19
+ }
20
+
21
+ export const cacheVSCodeVersion = async () => {
22
+ const response = await getVSCodeVersion()
23
+ state.vsCodeVersion = response
24
+
25
+ consola.info(`Using VSCode version: ${response}`)
26
+ }
src/main.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { defineCommand, runMain } from "citty"
4
+
5
+ import { auth } from "./auth"
6
+ import { checkUsage } from "./check-usage"
7
+ import { debug } from "./debug"
8
+ import { start } from "./start"
9
+
10
+ const main = defineCommand({
11
+ meta: {
12
+ name: "copilot-api",
13
+ description:
14
+ "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.",
15
+ },
16
+ subCommands: { auth, start, "check-usage": checkUsage, debug },
17
+ })
18
+
19
+ await runMain(main)
src/routes/chat-completions/handler.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context } from "hono"
2
+
3
+ import consola from "consola"
4
+ import { streamSSE, type SSEMessage } from "hono/streaming"
5
+
6
+ import { awaitApproval } from "~/lib/approval"
7
+ import { checkRateLimit } from "~/lib/rate-limit"
8
+ import { state } from "~/lib/state"
9
+ import { getTokenCount } from "~/lib/tokenizer"
10
+ import { isNullish } from "~/lib/utils"
11
+ import {
12
+ createChatCompletions,
13
+ type ChatCompletionResponse,
14
+ type ChatCompletionsPayload,
15
+ } from "~/services/copilot/create-chat-completions"
16
+
17
+ export async function handleCompletion(c: Context) {
18
+ await checkRateLimit(state)
19
+
20
+ let payload = await c.req.json<ChatCompletionsPayload>()
21
+ consola.debug("Request payload:", JSON.stringify(payload).slice(-400))
22
+
23
+ // Find the selected model
24
+ const selectedModel = state.models?.data.find(
25
+ (model) => model.id === payload.model,
26
+ )
27
+
28
+ // Calculate and display token count
29
+ try {
30
+ if (selectedModel) {
31
+ const tokenCount = await getTokenCount(payload, selectedModel)
32
+ consola.info("Current token count:", tokenCount)
33
+ } else {
34
+ consola.warn("No model selected, skipping token count calculation")
35
+ }
36
+ } catch (error) {
37
+ consola.warn("Failed to calculate token count:", error)
38
+ }
39
+
40
+ if (state.manualApprove) await awaitApproval()
41
+
42
+ if (isNullish(payload.max_tokens)) {
43
+ payload = {
44
+ ...payload,
45
+ max_tokens: selectedModel?.capabilities.limits.max_output_tokens,
46
+ }
47
+ consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens))
48
+ }
49
+
50
+ const response = await createChatCompletions(payload)
51
+
52
+ if (isNonStreaming(response)) {
53
+ consola.debug("Non-streaming response:", JSON.stringify(response))
54
+ return c.json(response)
55
+ }
56
+
57
+ consola.debug("Streaming response")
58
+ return streamSSE(c, async (stream) => {
59
+ for await (const chunk of response) {
60
+ consola.debug("Streaming chunk:", JSON.stringify(chunk))
61
+ await stream.writeSSE(chunk as SSEMessage)
62
+ }
63
+ })
64
+ }
65
+
66
+ const isNonStreaming = (
67
+ response: Awaited<ReturnType<typeof createChatCompletions>>,
68
+ ): response is ChatCompletionResponse => Object.hasOwn(response, "choices")
src/routes/chat-completions/route.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { forwardError } from "~/lib/error"
4
+
5
+ import { handleCompletion } from "./handler"
6
+
7
+ export const completionRoutes = new Hono()
8
+
9
+ completionRoutes.post("/", async (c) => {
10
+ try {
11
+ return await handleCompletion(c)
12
+ } catch (error) {
13
+ return await forwardError(c, error)
14
+ }
15
+ })
src/routes/embeddings/route.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { forwardError } from "~/lib/error"
4
+ import {
5
+ createEmbeddings,
6
+ type EmbeddingRequest,
7
+ } from "~/services/copilot/create-embeddings"
8
+
9
+ export const embeddingRoutes = new Hono()
10
+
11
+ embeddingRoutes.post("/", async (c) => {
12
+ try {
13
+ const paylod = await c.req.json<EmbeddingRequest>()
14
+ const response = await createEmbeddings(paylod)
15
+
16
+ return c.json(response)
17
+ } catch (error) {
18
+ return await forwardError(c, error)
19
+ }
20
+ })
src/routes/messages/anthropic-types.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Anthropic API Types
2
+
3
+ export interface AnthropicMessagesPayload {
4
+ model: string
5
+ messages: Array<AnthropicMessage>
6
+ max_tokens: number
7
+ system?: string | Array<AnthropicTextBlock>
8
+ metadata?: {
9
+ user_id?: string
10
+ }
11
+ stop_sequences?: Array<string>
12
+ stream?: boolean
13
+ temperature?: number
14
+ top_p?: number
15
+ top_k?: number
16
+ tools?: Array<AnthropicTool>
17
+ tool_choice?: {
18
+ type: "auto" | "any" | "tool" | "none"
19
+ name?: string
20
+ }
21
+ thinking?: {
22
+ type: "enabled"
23
+ budget_tokens?: number
24
+ }
25
+ service_tier?: "auto" | "standard_only"
26
+ }
27
+
28
+ export interface AnthropicTextBlock {
29
+ type: "text"
30
+ text: string
31
+ }
32
+
33
+ export interface AnthropicImageBlock {
34
+ type: "image"
35
+ source: {
36
+ type: "base64"
37
+ media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"
38
+ data: string
39
+ }
40
+ }
41
+
42
+ export interface AnthropicToolResultBlock {
43
+ type: "tool_result"
44
+ tool_use_id: string
45
+ content: string
46
+ is_error?: boolean
47
+ }
48
+
49
+ export interface AnthropicToolUseBlock {
50
+ type: "tool_use"
51
+ id: string
52
+ name: string
53
+ input: Record<string, unknown>
54
+ }
55
+
56
+ export interface AnthropicThinkingBlock {
57
+ type: "thinking"
58
+ thinking: string
59
+ }
60
+
61
+ export type AnthropicUserContentBlock =
62
+ | AnthropicTextBlock
63
+ | AnthropicImageBlock
64
+ | AnthropicToolResultBlock
65
+
66
+ export type AnthropicAssistantContentBlock =
67
+ | AnthropicTextBlock
68
+ | AnthropicToolUseBlock
69
+ | AnthropicThinkingBlock
70
+
71
+ export interface AnthropicUserMessage {
72
+ role: "user"
73
+ content: string | Array<AnthropicUserContentBlock>
74
+ }
75
+
76
+ export interface AnthropicAssistantMessage {
77
+ role: "assistant"
78
+ content: string | Array<AnthropicAssistantContentBlock>
79
+ }
80
+
81
+ export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
82
+
83
+ export interface AnthropicTool {
84
+ name: string
85
+ description?: string
86
+ input_schema: Record<string, unknown>
87
+ }
88
+
89
+ export interface AnthropicResponse {
90
+ id: string
91
+ type: "message"
92
+ role: "assistant"
93
+ content: Array<AnthropicAssistantContentBlock>
94
+ model: string
95
+ stop_reason:
96
+ | "end_turn"
97
+ | "max_tokens"
98
+ | "stop_sequence"
99
+ | "tool_use"
100
+ | "pause_turn"
101
+ | "refusal"
102
+ | null
103
+ stop_sequence: string | null
104
+ usage: {
105
+ input_tokens: number
106
+ output_tokens: number
107
+ cache_creation_input_tokens?: number
108
+ cache_read_input_tokens?: number
109
+ service_tier?: "standard" | "priority" | "batch"
110
+ }
111
+ }
112
+
113
+ export type AnthropicResponseContentBlock = AnthropicAssistantContentBlock
114
+
115
+ // Anthropic Stream Event Types
116
+ export interface AnthropicMessageStartEvent {
117
+ type: "message_start"
118
+ message: Omit<
119
+ AnthropicResponse,
120
+ "content" | "stop_reason" | "stop_sequence"
121
+ > & {
122
+ content: []
123
+ stop_reason: null
124
+ stop_sequence: null
125
+ }
126
+ }
127
+
128
+ export interface AnthropicContentBlockStartEvent {
129
+ type: "content_block_start"
130
+ index: number
131
+ content_block:
132
+ | { type: "text"; text: string }
133
+ | (Omit<AnthropicToolUseBlock, "input"> & {
134
+ input: Record<string, unknown>
135
+ })
136
+ | { type: "thinking"; thinking: string }
137
+ }
138
+
139
+ export interface AnthropicContentBlockDeltaEvent {
140
+ type: "content_block_delta"
141
+ index: number
142
+ delta:
143
+ | { type: "text_delta"; text: string }
144
+ | { type: "input_json_delta"; partial_json: string }
145
+ | { type: "thinking_delta"; thinking: string }
146
+ | { type: "signature_delta"; signature: string }
147
+ }
148
+
149
+ export interface AnthropicContentBlockStopEvent {
150
+ type: "content_block_stop"
151
+ index: number
152
+ }
153
+
154
+ export interface AnthropicMessageDeltaEvent {
155
+ type: "message_delta"
156
+ delta: {
157
+ stop_reason?: AnthropicResponse["stop_reason"]
158
+ stop_sequence?: string | null
159
+ }
160
+ usage?: {
161
+ input_tokens?: number
162
+ output_tokens: number
163
+ cache_creation_input_tokens?: number
164
+ cache_read_input_tokens?: number
165
+ }
166
+ }
167
+
168
+ export interface AnthropicMessageStopEvent {
169
+ type: "message_stop"
170
+ }
171
+
172
+ export interface AnthropicPingEvent {
173
+ type: "ping"
174
+ }
175
+
176
+ export interface AnthropicErrorEvent {
177
+ type: "error"
178
+ error: {
179
+ type: string
180
+ message: string
181
+ }
182
+ }
183
+
184
+ export type AnthropicStreamEventData =
185
+ | AnthropicMessageStartEvent
186
+ | AnthropicContentBlockStartEvent
187
+ | AnthropicContentBlockDeltaEvent
188
+ | AnthropicContentBlockStopEvent
189
+ | AnthropicMessageDeltaEvent
190
+ | AnthropicMessageStopEvent
191
+ | AnthropicPingEvent
192
+ | AnthropicErrorEvent
193
+
194
+ // State for streaming translation
195
+ export interface AnthropicStreamState {
196
+ messageStartSent: boolean
197
+ contentBlockIndex: number
198
+ contentBlockOpen: boolean
199
+ toolCalls: {
200
+ [openAIToolIndex: number]: {
201
+ id: string
202
+ name: string
203
+ anthropicBlockIndex: number
204
+ }
205
+ }
206
+ }
src/routes/messages/count-tokens-handler.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context } from "hono"
2
+
3
+ import consola from "consola"
4
+
5
+ import { state } from "~/lib/state"
6
+ import { getTokenCount } from "~/lib/tokenizer"
7
+
8
+ import { type AnthropicMessagesPayload } from "./anthropic-types"
9
+ import { translateToOpenAI } from "./non-stream-translation"
10
+
11
+ /**
12
+ * Handles token counting for Anthropic messages
13
+ */
14
+ export async function handleCountTokens(c: Context) {
15
+ try {
16
+ const anthropicBeta = c.req.header("anthropic-beta")
17
+
18
+ const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
19
+
20
+ const openAIPayload = translateToOpenAI(anthropicPayload)
21
+
22
+ const selectedModel = state.models?.data.find(
23
+ (model) => model.id === anthropicPayload.model,
24
+ )
25
+
26
+ if (!selectedModel) {
27
+ consola.warn("Model not found, returning default token count")
28
+ return c.json({
29
+ input_tokens: 1,
30
+ })
31
+ }
32
+
33
+ const tokenCount = await getTokenCount(openAIPayload, selectedModel)
34
+
35
+ if (anthropicPayload.tools && anthropicPayload.tools.length > 0) {
36
+ let mcpToolExist = false
37
+ if (anthropicBeta?.startsWith("claude-code")) {
38
+ mcpToolExist = anthropicPayload.tools.some((tool) =>
39
+ tool.name.startsWith("mcp__"),
40
+ )
41
+ }
42
+ if (!mcpToolExist) {
43
+ if (anthropicPayload.model.startsWith("claude")) {
44
+ // https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview#pricing
45
+ tokenCount.input = tokenCount.input + 346
46
+ } else if (anthropicPayload.model.startsWith("grok")) {
47
+ tokenCount.input = tokenCount.input + 480
48
+ }
49
+ }
50
+ }
51
+
52
+ let finalTokenCount = tokenCount.input + tokenCount.output
53
+ if (anthropicPayload.model.startsWith("claude")) {
54
+ finalTokenCount = Math.round(finalTokenCount * 1.15)
55
+ } else if (anthropicPayload.model.startsWith("grok")) {
56
+ finalTokenCount = Math.round(finalTokenCount * 1.03)
57
+ }
58
+
59
+ consola.info("Token count:", finalTokenCount)
60
+
61
+ return c.json({
62
+ input_tokens: finalTokenCount,
63
+ })
64
+ } catch (error) {
65
+ consola.error("Error counting tokens:", error)
66
+ return c.json({
67
+ input_tokens: 1,
68
+ })
69
+ }
70
+ }
src/routes/messages/handler.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context } from "hono"
2
+
3
+ import consola from "consola"
4
+ import { streamSSE } from "hono/streaming"
5
+
6
+ import { awaitApproval } from "~/lib/approval"
7
+ import { checkRateLimit } from "~/lib/rate-limit"
8
+ import { state } from "~/lib/state"
9
+ import {
10
+ createChatCompletions,
11
+ type ChatCompletionChunk,
12
+ type ChatCompletionResponse,
13
+ } from "~/services/copilot/create-chat-completions"
14
+
15
+ import {
16
+ type AnthropicMessagesPayload,
17
+ type AnthropicStreamState,
18
+ } from "./anthropic-types"
19
+ import {
20
+ translateToAnthropic,
21
+ translateToOpenAI,
22
+ } from "./non-stream-translation"
23
+ import { translateChunkToAnthropicEvents } from "./stream-translation"
24
+
25
+ export async function handleCompletion(c: Context) {
26
+ await checkRateLimit(state)
27
+
28
+ const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
29
+ consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))
30
+
31
+ const openAIPayload = translateToOpenAI(anthropicPayload)
32
+ consola.debug(
33
+ "Translated OpenAI request payload:",
34
+ JSON.stringify(openAIPayload),
35
+ )
36
+
37
+ if (state.manualApprove) {
38
+ await awaitApproval()
39
+ }
40
+
41
+ const response = await createChatCompletions(openAIPayload)
42
+
43
+ if (isNonStreaming(response)) {
44
+ consola.debug(
45
+ "Non-streaming response from Copilot:",
46
+ JSON.stringify(response).slice(-400),
47
+ )
48
+ const anthropicResponse = translateToAnthropic(response)
49
+ consola.debug(
50
+ "Translated Anthropic response:",
51
+ JSON.stringify(anthropicResponse),
52
+ )
53
+ return c.json(anthropicResponse)
54
+ }
55
+
56
+ consola.debug("Streaming response from Copilot")
57
+ return streamSSE(c, async (stream) => {
58
+ const streamState: AnthropicStreamState = {
59
+ messageStartSent: false,
60
+ contentBlockIndex: 0,
61
+ contentBlockOpen: false,
62
+ toolCalls: {},
63
+ }
64
+
65
+ for await (const rawEvent of response) {
66
+ consola.debug("Copilot raw stream event:", JSON.stringify(rawEvent))
67
+ if (rawEvent.data === "[DONE]") {
68
+ break
69
+ }
70
+
71
+ if (!rawEvent.data) {
72
+ continue
73
+ }
74
+
75
+ const chunk = JSON.parse(rawEvent.data) as ChatCompletionChunk
76
+ const events = translateChunkToAnthropicEvents(chunk, streamState)
77
+
78
+ for (const event of events) {
79
+ consola.debug("Translated Anthropic event:", JSON.stringify(event))
80
+ await stream.writeSSE({
81
+ event: event.type,
82
+ data: JSON.stringify(event),
83
+ })
84
+ }
85
+ }
86
+ })
87
+ }
88
+
89
+ const isNonStreaming = (
90
+ response: Awaited<ReturnType<typeof createChatCompletions>>,
91
+ ): response is ChatCompletionResponse => Object.hasOwn(response, "choices")
src/routes/messages/non-stream-translation.ts ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type ChatCompletionResponse,
3
+ type ChatCompletionsPayload,
4
+ type ContentPart,
5
+ type Message,
6
+ type TextPart,
7
+ type Tool,
8
+ type ToolCall,
9
+ } from "~/services/copilot/create-chat-completions"
10
+
11
+ import {
12
+ type AnthropicAssistantContentBlock,
13
+ type AnthropicAssistantMessage,
14
+ type AnthropicMessage,
15
+ type AnthropicMessagesPayload,
16
+ type AnthropicResponse,
17
+ type AnthropicTextBlock,
18
+ type AnthropicThinkingBlock,
19
+ type AnthropicTool,
20
+ type AnthropicToolResultBlock,
21
+ type AnthropicToolUseBlock,
22
+ type AnthropicUserContentBlock,
23
+ type AnthropicUserMessage,
24
+ } from "./anthropic-types"
25
+ import { mapOpenAIStopReasonToAnthropic } from "./utils"
26
+
27
+ // Payload translation
28
+
29
+ export function translateToOpenAI(
30
+ payload: AnthropicMessagesPayload,
31
+ ): ChatCompletionsPayload {
32
+ return {
33
+ model: translateModelName(payload.model),
34
+ messages: translateAnthropicMessagesToOpenAI(
35
+ payload.messages,
36
+ payload.system,
37
+ ),
38
+ max_tokens: payload.max_tokens,
39
+ stop: payload.stop_sequences,
40
+ stream: payload.stream,
41
+ temperature: payload.temperature,
42
+ top_p: payload.top_p,
43
+ user: payload.metadata?.user_id,
44
+ tools: translateAnthropicToolsToOpenAI(payload.tools),
45
+ tool_choice: translateAnthropicToolChoiceToOpenAI(payload.tool_choice),
46
+ }
47
+ }
48
+
49
+ function translateModelName(model: string): string {
50
+ // Subagent requests use a specific model number which Copilot doesn't support
51
+ if (model.startsWith("claude-sonnet-4-")) {
52
+ return model.replace(/^claude-sonnet-4-.*/, "claude-sonnet-4")
53
+ } else if (model.startsWith("claude-opus-")) {
54
+ return model.replace(/^claude-opus-4-.*/, "claude-opus-4")
55
+ }
56
+ return model
57
+ }
58
+
59
+ function translateAnthropicMessagesToOpenAI(
60
+ anthropicMessages: Array<AnthropicMessage>,
61
+ system: string | Array<AnthropicTextBlock> | undefined,
62
+ ): Array<Message> {
63
+ const systemMessages = handleSystemPrompt(system)
64
+
65
+ const otherMessages = anthropicMessages.flatMap((message) =>
66
+ message.role === "user" ?
67
+ handleUserMessage(message)
68
+ : handleAssistantMessage(message),
69
+ )
70
+
71
+ return [...systemMessages, ...otherMessages]
72
+ }
73
+
74
+ function handleSystemPrompt(
75
+ system: string | Array<AnthropicTextBlock> | undefined,
76
+ ): Array<Message> {
77
+ if (!system) {
78
+ return []
79
+ }
80
+
81
+ if (typeof system === "string") {
82
+ return [{ role: "system", content: system }]
83
+ } else {
84
+ const systemText = system.map((block) => block.text).join("\n\n")
85
+ return [{ role: "system", content: systemText }]
86
+ }
87
+ }
88
+
89
+ function handleUserMessage(message: AnthropicUserMessage): Array<Message> {
90
+ const newMessages: Array<Message> = []
91
+
92
+ if (Array.isArray(message.content)) {
93
+ const toolResultBlocks = message.content.filter(
94
+ (block): block is AnthropicToolResultBlock =>
95
+ block.type === "tool_result",
96
+ )
97
+ const otherBlocks = message.content.filter(
98
+ (block) => block.type !== "tool_result",
99
+ )
100
+
101
+ // Tool results must come first to maintain protocol: tool_use -> tool_result -> user
102
+ for (const block of toolResultBlocks) {
103
+ newMessages.push({
104
+ role: "tool",
105
+ tool_call_id: block.tool_use_id,
106
+ content: mapContent(block.content),
107
+ })
108
+ }
109
+
110
+ if (otherBlocks.length > 0) {
111
+ newMessages.push({
112
+ role: "user",
113
+ content: mapContent(otherBlocks),
114
+ })
115
+ }
116
+ } else {
117
+ newMessages.push({
118
+ role: "user",
119
+ content: mapContent(message.content),
120
+ })
121
+ }
122
+
123
+ return newMessages
124
+ }
125
+
126
+ function handleAssistantMessage(
127
+ message: AnthropicAssistantMessage,
128
+ ): Array<Message> {
129
+ if (!Array.isArray(message.content)) {
130
+ return [
131
+ {
132
+ role: "assistant",
133
+ content: mapContent(message.content),
134
+ },
135
+ ]
136
+ }
137
+
138
+ const toolUseBlocks = message.content.filter(
139
+ (block): block is AnthropicToolUseBlock => block.type === "tool_use",
140
+ )
141
+
142
+ const textBlocks = message.content.filter(
143
+ (block): block is AnthropicTextBlock => block.type === "text",
144
+ )
145
+
146
+ const thinkingBlocks = message.content.filter(
147
+ (block): block is AnthropicThinkingBlock => block.type === "thinking",
148
+ )
149
+
150
+ // Combine text and thinking blocks, as OpenAI doesn't have separate thinking blocks
151
+ const allTextContent = [
152
+ ...textBlocks.map((b) => b.text),
153
+ ...thinkingBlocks.map((b) => b.thinking),
154
+ ].join("\n\n")
155
+
156
+ return toolUseBlocks.length > 0 ?
157
+ [
158
+ {
159
+ role: "assistant",
160
+ content: allTextContent || null,
161
+ tool_calls: toolUseBlocks.map((toolUse) => ({
162
+ id: toolUse.id,
163
+ type: "function",
164
+ function: {
165
+ name: toolUse.name,
166
+ arguments: JSON.stringify(toolUse.input),
167
+ },
168
+ })),
169
+ },
170
+ ]
171
+ : [
172
+ {
173
+ role: "assistant",
174
+ content: mapContent(message.content),
175
+ },
176
+ ]
177
+ }
178
+
179
+ function mapContent(
180
+ content:
181
+ | string
182
+ | Array<AnthropicUserContentBlock | AnthropicAssistantContentBlock>,
183
+ ): string | Array<ContentPart> | null {
184
+ if (typeof content === "string") {
185
+ return content
186
+ }
187
+ if (!Array.isArray(content)) {
188
+ return null
189
+ }
190
+
191
+ const hasImage = content.some((block) => block.type === "image")
192
+ if (!hasImage) {
193
+ return content
194
+ .filter(
195
+ (block): block is AnthropicTextBlock | AnthropicThinkingBlock =>
196
+ block.type === "text" || block.type === "thinking",
197
+ )
198
+ .map((block) => (block.type === "text" ? block.text : block.thinking))
199
+ .join("\n\n")
200
+ }
201
+
202
+ const contentParts: Array<ContentPart> = []
203
+ for (const block of content) {
204
+ switch (block.type) {
205
+ case "text": {
206
+ contentParts.push({ type: "text", text: block.text })
207
+
208
+ break
209
+ }
210
+ case "thinking": {
211
+ contentParts.push({ type: "text", text: block.thinking })
212
+
213
+ break
214
+ }
215
+ case "image": {
216
+ contentParts.push({
217
+ type: "image_url",
218
+ image_url: {
219
+ url: `data:${block.source.media_type};base64,${block.source.data}`,
220
+ },
221
+ })
222
+
223
+ break
224
+ }
225
+ // No default
226
+ }
227
+ }
228
+ return contentParts
229
+ }
230
+
231
+ function translateAnthropicToolsToOpenAI(
232
+ anthropicTools: Array<AnthropicTool> | undefined,
233
+ ): Array<Tool> | undefined {
234
+ if (!anthropicTools) {
235
+ return undefined
236
+ }
237
+ return anthropicTools.map((tool) => ({
238
+ type: "function",
239
+ function: {
240
+ name: tool.name,
241
+ description: tool.description,
242
+ parameters: tool.input_schema,
243
+ },
244
+ }))
245
+ }
246
+
247
+ function translateAnthropicToolChoiceToOpenAI(
248
+ anthropicToolChoice: AnthropicMessagesPayload["tool_choice"],
249
+ ): ChatCompletionsPayload["tool_choice"] {
250
+ if (!anthropicToolChoice) {
251
+ return undefined
252
+ }
253
+
254
+ switch (anthropicToolChoice.type) {
255
+ case "auto": {
256
+ return "auto"
257
+ }
258
+ case "any": {
259
+ return "required"
260
+ }
261
+ case "tool": {
262
+ if (anthropicToolChoice.name) {
263
+ return {
264
+ type: "function",
265
+ function: { name: anthropicToolChoice.name },
266
+ }
267
+ }
268
+ return undefined
269
+ }
270
+ case "none": {
271
+ return "none"
272
+ }
273
+ default: {
274
+ return undefined
275
+ }
276
+ }
277
+ }
278
+
279
+ // Response translation
280
+
281
+ export function translateToAnthropic(
282
+ response: ChatCompletionResponse,
283
+ ): AnthropicResponse {
284
+ // Merge content from all choices
285
+ const allTextBlocks: Array<AnthropicTextBlock> = []
286
+ const allToolUseBlocks: Array<AnthropicToolUseBlock> = []
287
+ let stopReason: "stop" | "length" | "tool_calls" | "content_filter" | null =
288
+ null // default
289
+ stopReason = response.choices[0]?.finish_reason ?? stopReason
290
+
291
+ // Process all choices to extract text and tool use blocks
292
+ for (const choice of response.choices) {
293
+ const textBlocks = getAnthropicTextBlocks(choice.message.content)
294
+ const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls)
295
+
296
+ allTextBlocks.push(...textBlocks)
297
+ allToolUseBlocks.push(...toolUseBlocks)
298
+
299
+ // Use the finish_reason from the first choice, or prioritize tool_calls
300
+ if (choice.finish_reason === "tool_calls" || stopReason === "stop") {
301
+ stopReason = choice.finish_reason
302
+ }
303
+ }
304
+
305
+ // Note: GitHub Copilot doesn't generate thinking blocks, so we don't include them in responses
306
+
307
+ return {
308
+ id: response.id,
309
+ type: "message",
310
+ role: "assistant",
311
+ model: response.model,
312
+ content: [...allTextBlocks, ...allToolUseBlocks],
313
+ stop_reason: mapOpenAIStopReasonToAnthropic(stopReason),
314
+ stop_sequence: null,
315
+ usage: {
316
+ input_tokens:
317
+ (response.usage?.prompt_tokens ?? 0)
318
+ - (response.usage?.prompt_tokens_details?.cached_tokens ?? 0),
319
+ output_tokens: response.usage?.completion_tokens ?? 0,
320
+ ...(response.usage?.prompt_tokens_details?.cached_tokens
321
+ !== undefined && {
322
+ cache_read_input_tokens:
323
+ response.usage.prompt_tokens_details.cached_tokens,
324
+ }),
325
+ },
326
+ }
327
+ }
328
+
329
+ function getAnthropicTextBlocks(
330
+ messageContent: Message["content"],
331
+ ): Array<AnthropicTextBlock> {
332
+ if (typeof messageContent === "string") {
333
+ return [{ type: "text", text: messageContent }]
334
+ }
335
+
336
+ if (Array.isArray(messageContent)) {
337
+ return messageContent
338
+ .filter((part): part is TextPart => part.type === "text")
339
+ .map((part) => ({ type: "text", text: part.text }))
340
+ }
341
+
342
+ return []
343
+ }
344
+
345
+ function getAnthropicToolUseBlocks(
346
+ toolCalls: Array<ToolCall> | undefined,
347
+ ): Array<AnthropicToolUseBlock> {
348
+ if (!toolCalls) {
349
+ return []
350
+ }
351
+ return toolCalls.map((toolCall) => ({
352
+ type: "tool_use",
353
+ id: toolCall.id,
354
+ name: toolCall.function.name,
355
+ input: JSON.parse(toolCall.function.arguments) as Record<string, unknown>,
356
+ }))
357
+ }
src/routes/messages/route.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { forwardError } from "~/lib/error"
4
+
5
+ import { handleCountTokens } from "./count-tokens-handler"
6
+ import { handleCompletion } from "./handler"
7
+
8
+ export const messageRoutes = new Hono()
9
+
10
+ messageRoutes.post("/", async (c) => {
11
+ try {
12
+ return await handleCompletion(c)
13
+ } catch (error) {
14
+ return await forwardError(c, error)
15
+ }
16
+ })
17
+
18
+ messageRoutes.post("/count_tokens", async (c) => {
19
+ try {
20
+ return await handleCountTokens(c)
21
+ } catch (error) {
22
+ return await forwardError(c, error)
23
+ }
24
+ })
src/routes/messages/stream-translation.ts ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ChatCompletionChunk } from "~/services/copilot/create-chat-completions"
2
+
3
+ import {
4
+ type AnthropicStreamEventData,
5
+ type AnthropicStreamState,
6
+ } from "./anthropic-types"
7
+ import { mapOpenAIStopReasonToAnthropic } from "./utils"
8
+
9
+ function isToolBlockOpen(state: AnthropicStreamState): boolean {
10
+ if (!state.contentBlockOpen) {
11
+ return false
12
+ }
13
+ // Check if the current block index corresponds to any known tool call
14
+ return Object.values(state.toolCalls).some(
15
+ (tc) => tc.anthropicBlockIndex === state.contentBlockIndex,
16
+ )
17
+ }
18
+
19
+ // eslint-disable-next-line max-lines-per-function, complexity
20
+ export function translateChunkToAnthropicEvents(
21
+ chunk: ChatCompletionChunk,
22
+ state: AnthropicStreamState,
23
+ ): Array<AnthropicStreamEventData> {
24
+ const events: Array<AnthropicStreamEventData> = []
25
+
26
+ if (chunk.choices.length === 0) {
27
+ return events
28
+ }
29
+
30
+ const choice = chunk.choices[0]
31
+ const { delta } = choice
32
+
33
+ if (!state.messageStartSent) {
34
+ events.push({
35
+ type: "message_start",
36
+ message: {
37
+ id: chunk.id,
38
+ type: "message",
39
+ role: "assistant",
40
+ content: [],
41
+ model: chunk.model,
42
+ stop_reason: null,
43
+ stop_sequence: null,
44
+ usage: {
45
+ input_tokens:
46
+ (chunk.usage?.prompt_tokens ?? 0)
47
+ - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
48
+ output_tokens: 0, // Will be updated in message_delta when finished
49
+ ...(chunk.usage?.prompt_tokens_details?.cached_tokens
50
+ !== undefined && {
51
+ cache_read_input_tokens:
52
+ chunk.usage.prompt_tokens_details.cached_tokens,
53
+ }),
54
+ },
55
+ },
56
+ })
57
+ state.messageStartSent = true
58
+ }
59
+
60
+ if (delta.content) {
61
+ if (isToolBlockOpen(state)) {
62
+ // A tool block was open, so close it before starting a text block.
63
+ events.push({
64
+ type: "content_block_stop",
65
+ index: state.contentBlockIndex,
66
+ })
67
+ state.contentBlockIndex++
68
+ state.contentBlockOpen = false
69
+ }
70
+
71
+ if (!state.contentBlockOpen) {
72
+ events.push({
73
+ type: "content_block_start",
74
+ index: state.contentBlockIndex,
75
+ content_block: {
76
+ type: "text",
77
+ text: "",
78
+ },
79
+ })
80
+ state.contentBlockOpen = true
81
+ }
82
+
83
+ events.push({
84
+ type: "content_block_delta",
85
+ index: state.contentBlockIndex,
86
+ delta: {
87
+ type: "text_delta",
88
+ text: delta.content,
89
+ },
90
+ })
91
+ }
92
+
93
+ if (delta.tool_calls) {
94
+ for (const toolCall of delta.tool_calls) {
95
+ if (toolCall.id && toolCall.function?.name) {
96
+ // New tool call starting.
97
+ if (state.contentBlockOpen) {
98
+ // Close any previously open block.
99
+ events.push({
100
+ type: "content_block_stop",
101
+ index: state.contentBlockIndex,
102
+ })
103
+ state.contentBlockIndex++
104
+ state.contentBlockOpen = false
105
+ }
106
+
107
+ const anthropicBlockIndex = state.contentBlockIndex
108
+ state.toolCalls[toolCall.index] = {
109
+ id: toolCall.id,
110
+ name: toolCall.function.name,
111
+ anthropicBlockIndex,
112
+ }
113
+
114
+ events.push({
115
+ type: "content_block_start",
116
+ index: anthropicBlockIndex,
117
+ content_block: {
118
+ type: "tool_use",
119
+ id: toolCall.id,
120
+ name: toolCall.function.name,
121
+ input: {},
122
+ },
123
+ })
124
+ state.contentBlockOpen = true
125
+ }
126
+
127
+ if (toolCall.function?.arguments) {
128
+ const toolCallInfo = state.toolCalls[toolCall.index]
129
+ // Tool call can still be empty
130
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
131
+ if (toolCallInfo) {
132
+ events.push({
133
+ type: "content_block_delta",
134
+ index: toolCallInfo.anthropicBlockIndex,
135
+ delta: {
136
+ type: "input_json_delta",
137
+ partial_json: toolCall.function.arguments,
138
+ },
139
+ })
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ if (choice.finish_reason) {
146
+ if (state.contentBlockOpen) {
147
+ events.push({
148
+ type: "content_block_stop",
149
+ index: state.contentBlockIndex,
150
+ })
151
+ state.contentBlockOpen = false
152
+ }
153
+
154
+ events.push(
155
+ {
156
+ type: "message_delta",
157
+ delta: {
158
+ stop_reason: mapOpenAIStopReasonToAnthropic(choice.finish_reason),
159
+ stop_sequence: null,
160
+ },
161
+ usage: {
162
+ input_tokens:
163
+ (chunk.usage?.prompt_tokens ?? 0)
164
+ - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0),
165
+ output_tokens: chunk.usage?.completion_tokens ?? 0,
166
+ ...(chunk.usage?.prompt_tokens_details?.cached_tokens
167
+ !== undefined && {
168
+ cache_read_input_tokens:
169
+ chunk.usage.prompt_tokens_details.cached_tokens,
170
+ }),
171
+ },
172
+ },
173
+ {
174
+ type: "message_stop",
175
+ },
176
+ )
177
+ }
178
+
179
+ return events
180
+ }
181
+
182
+ export function translateErrorToAnthropicErrorEvent(): AnthropicStreamEventData {
183
+ return {
184
+ type: "error",
185
+ error: {
186
+ type: "api_error",
187
+ message: "An unexpected error occurred during streaming.",
188
+ },
189
+ }
190
+ }
src/routes/messages/utils.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type AnthropicResponse } from "./anthropic-types"
2
+
3
+ export function mapOpenAIStopReasonToAnthropic(
4
+ finishReason: "stop" | "length" | "tool_calls" | "content_filter" | null,
5
+ ): AnthropicResponse["stop_reason"] {
6
+ if (finishReason === null) {
7
+ return null
8
+ }
9
+ const stopReasonMap = {
10
+ stop: "end_turn",
11
+ length: "max_tokens",
12
+ tool_calls: "tool_use",
13
+ content_filter: "end_turn",
14
+ } as const
15
+ return stopReasonMap[finishReason]
16
+ }
src/routes/models/route.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { forwardError } from "~/lib/error"
4
+ import { state } from "~/lib/state"
5
+ import { cacheModels } from "~/lib/utils"
6
+
7
+ export const modelRoutes = new Hono()
8
+
9
+ modelRoutes.get("/", async (c) => {
10
+ try {
11
+ if (!state.models) {
12
+ // This should be handled by startup logic, but as a fallback.
13
+ await cacheModels()
14
+ }
15
+
16
+ const models = state.models?.data.map((model) => ({
17
+ id: model.id,
18
+ object: "model",
19
+ type: "model",
20
+ created: 0, // No date available from source
21
+ created_at: new Date(0).toISOString(), // No date available from source
22
+ owned_by: model.vendor,
23
+ display_name: model.name,
24
+ }))
25
+
26
+ return c.json({
27
+ object: "list",
28
+ data: models,
29
+ has_more: false,
30
+ })
31
+ } catch (error) {
32
+ return await forwardError(c, error)
33
+ }
34
+ })
src/routes/token/route.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { state } from "~/lib/state"
4
+
5
+ export const tokenRoute = new Hono()
6
+
7
+ tokenRoute.get("/", (c) => {
8
+ try {
9
+ return c.json({
10
+ token: state.copilotToken,
11
+ })
12
+ } catch (error) {
13
+ console.error("Error fetching token:", error)
14
+ return c.json({ error: "Failed to fetch token", token: null }, 500)
15
+ }
16
+ })
src/routes/usage/route.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+
3
+ import { getCopilotUsage } from "~/services/github/get-copilot-usage"
4
+
5
+ export const usageRoute = new Hono()
6
+
7
+ usageRoute.get("/", async (c) => {
8
+ try {
9
+ const usage = await getCopilotUsage()
10
+ return c.json(usage)
11
+ } catch (error) {
12
+ console.error("Error fetching Copilot usage:", error)
13
+ return c.json({ error: "Failed to fetch Copilot usage" }, 500)
14
+ }
15
+ })
src/server.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hono } from "hono"
2
+ import { cors } from "hono/cors"
3
+ import { logger } from "hono/logger"
4
+
5
+ import { completionRoutes } from "./routes/chat-completions/route"
6
+ import { embeddingRoutes } from "./routes/embeddings/route"
7
+ import { messageRoutes } from "./routes/messages/route"
8
+ import { modelRoutes } from "./routes/models/route"
9
+ import { tokenRoute } from "./routes/token/route"
10
+ import { usageRoute } from "./routes/usage/route"
11
+
12
+ export const server = new Hono()
13
+
14
+ server.use(logger())
15
+ server.use(cors())
16
+
17
+ server.get("/", (c) => c.text("Server running"))
18
+
19
+ server.route("/chat/completions", completionRoutes)
20
+ server.route("/models", modelRoutes)
21
+ server.route("/embeddings", embeddingRoutes)
22
+ server.route("/usage", usageRoute)
23
+ server.route("/token", tokenRoute)
24
+
25
+ // Compatibility with tools that expect v1/ prefix
26
+ server.route("/v1/chat/completions", completionRoutes)
27
+ server.route("/v1/models", modelRoutes)
28
+ server.route("/v1/embeddings", embeddingRoutes)
29
+
30
+ // Anthropic compatible endpoints
31
+ server.route("/v1/messages", messageRoutes)
src/services/copilot/create-chat-completions.ts ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import consola from "consola"
2
+ import { events } from "fetch-event-stream"
3
+
4
+ import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
5
+ import { HTTPError } from "~/lib/error"
6
+ import { state } from "~/lib/state"
7
+
8
+ export const createChatCompletions = async (
9
+ payload: ChatCompletionsPayload,
10
+ ) => {
11
+ if (!state.copilotToken) throw new Error("Copilot token not found")
12
+
13
+ const enableVision = payload.messages.some(
14
+ (x) =>
15
+ typeof x.content !== "string"
16
+ && x.content?.some((x) => x.type === "image_url"),
17
+ )
18
+
19
+ // Agent/user check for X-Initiator header
20
+ // Determine if any message is from an agent ("assistant" or "tool")
21
+ const isAgentCall = payload.messages.some((msg) =>
22
+ ["assistant", "tool"].includes(msg.role),
23
+ )
24
+
25
+ // Build headers and add X-Initiator
26
+ const headers: Record<string, string> = {
27
+ ...copilotHeaders(state, enableVision),
28
+ "X-Initiator": isAgentCall ? "agent" : "user",
29
+ }
30
+
31
+ const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {
32
+ method: "POST",
33
+ headers,
34
+ body: JSON.stringify(payload),
35
+ })
36
+
37
+ if (!response.ok) {
38
+ consola.error("Failed to create chat completions", response)
39
+ throw new HTTPError("Failed to create chat completions", response)
40
+ }
41
+
42
+ if (payload.stream) {
43
+ return events(response)
44
+ }
45
+
46
+ return (await response.json()) as ChatCompletionResponse
47
+ }
48
+
49
+ // Streaming types
50
+
51
+ export interface ChatCompletionChunk {
52
+ id: string
53
+ object: "chat.completion.chunk"
54
+ created: number
55
+ model: string
56
+ choices: Array<Choice>
57
+ system_fingerprint?: string
58
+ usage?: {
59
+ prompt_tokens: number
60
+ completion_tokens: number
61
+ total_tokens: number
62
+ prompt_tokens_details?: {
63
+ cached_tokens: number
64
+ }
65
+ completion_tokens_details?: {
66
+ accepted_prediction_tokens: number
67
+ rejected_prediction_tokens: number
68
+ }
69
+ }
70
+ }
71
+
72
+ interface Delta {
73
+ content?: string | null
74
+ role?: "user" | "assistant" | "system" | "tool"
75
+ tool_calls?: Array<{
76
+ index: number
77
+ id?: string
78
+ type?: "function"
79
+ function?: {
80
+ name?: string
81
+ arguments?: string
82
+ }
83
+ }>
84
+ }
85
+
86
+ interface Choice {
87
+ index: number
88
+ delta: Delta
89
+ finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null
90
+ logprobs: object | null
91
+ }
92
+
93
+ // Non-streaming types
94
+
95
+ export interface ChatCompletionResponse {
96
+ id: string
97
+ object: "chat.completion"
98
+ created: number
99
+ model: string
100
+ choices: Array<ChoiceNonStreaming>
101
+ system_fingerprint?: string
102
+ usage?: {
103
+ prompt_tokens: number
104
+ completion_tokens: number
105
+ total_tokens: number
106
+ prompt_tokens_details?: {
107
+ cached_tokens: number
108
+ }
109
+ }
110
+ }
111
+
112
+ interface ResponseMessage {
113
+ role: "assistant"
114
+ content: string | null
115
+ tool_calls?: Array<ToolCall>
116
+ }
117
+
118
+ interface ChoiceNonStreaming {
119
+ index: number
120
+ message: ResponseMessage
121
+ logprobs: object | null
122
+ finish_reason: "stop" | "length" | "tool_calls" | "content_filter"
123
+ }
124
+
125
+ // Payload types
126
+
127
+ export interface ChatCompletionsPayload {
128
+ messages: Array<Message>
129
+ model: string
130
+ temperature?: number | null
131
+ top_p?: number | null
132
+ max_tokens?: number | null
133
+ stop?: string | Array<string> | null
134
+ n?: number | null
135
+ stream?: boolean | null
136
+
137
+ frequency_penalty?: number | null
138
+ presence_penalty?: number | null
139
+ logit_bias?: Record<string, number> | null
140
+ logprobs?: boolean | null
141
+ response_format?: { type: "json_object" } | null
142
+ seed?: number | null
143
+ tools?: Array<Tool> | null
144
+ tool_choice?:
145
+ | "none"
146
+ | "auto"
147
+ | "required"
148
+ | { type: "function"; function: { name: string } }
149
+ | null
150
+ user?: string | null
151
+ }
152
+
153
+ export interface Tool {
154
+ type: "function"
155
+ function: {
156
+ name: string
157
+ description?: string
158
+ parameters: Record<string, unknown>
159
+ }
160
+ }
161
+
162
+ export interface Message {
163
+ role: "user" | "assistant" | "system" | "tool" | "developer"
164
+ content: string | Array<ContentPart> | null
165
+
166
+ name?: string
167
+ tool_calls?: Array<ToolCall>
168
+ tool_call_id?: string
169
+ }
170
+
171
+ export interface ToolCall {
172
+ id: string
173
+ type: "function"
174
+ function: {
175
+ name: string
176
+ arguments: string
177
+ }
178
+ }
179
+
180
+ export type ContentPart = TextPart | ImagePart
181
+
182
+ export interface TextPart {
183
+ type: "text"
184
+ text: string
185
+ }
186
+
187
+ export interface ImagePart {
188
+ type: "image_url"
189
+ image_url: {
190
+ url: string
191
+ detail?: "low" | "high" | "auto"
192
+ }
193
+ }
src/services/copilot/create-embeddings.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
2
+ import { HTTPError } from "~/lib/error"
3
+ import { state } from "~/lib/state"
4
+
5
+ export const createEmbeddings = async (payload: EmbeddingRequest) => {
6
+ if (!state.copilotToken) throw new Error("Copilot token not found")
7
+
8
+ const response = await fetch(`${copilotBaseUrl(state)}/embeddings`, {
9
+ method: "POST",
10
+ headers: copilotHeaders(state),
11
+ body: JSON.stringify(payload),
12
+ })
13
+
14
+ if (!response.ok) throw new HTTPError("Failed to create embeddings", response)
15
+
16
+ return (await response.json()) as EmbeddingResponse
17
+ }
18
+
19
+ export interface EmbeddingRequest {
20
+ input: string | Array<string>
21
+ model: string
22
+ }
23
+
24
+ export interface Embedding {
25
+ object: string
26
+ embedding: Array<number>
27
+ index: number
28
+ }
29
+
30
+ export interface EmbeddingResponse {
31
+ object: string
32
+ data: Array<Embedding>
33
+ model: string
34
+ usage: {
35
+ prompt_tokens: number
36
+ total_tokens: number
37
+ }
38
+ }
src/services/copilot/get-models.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { copilotBaseUrl, copilotHeaders } from "~/lib/api-config"
2
+ import { HTTPError } from "~/lib/error"
3
+ import { state } from "~/lib/state"
4
+
5
+ export const getModels = async () => {
6
+ const response = await fetch(`${copilotBaseUrl(state)}/models`, {
7
+ headers: copilotHeaders(state),
8
+ })
9
+
10
+ if (!response.ok) throw new HTTPError("Failed to get models", response)
11
+
12
+ return (await response.json()) as ModelsResponse
13
+ }
14
+
15
+ export interface ModelsResponse {
16
+ data: Array<Model>
17
+ object: string
18
+ }
19
+
20
+ interface ModelLimits {
21
+ max_context_window_tokens?: number
22
+ max_output_tokens?: number
23
+ max_prompt_tokens?: number
24
+ max_inputs?: number
25
+ }
26
+
27
+ interface ModelSupports {
28
+ tool_calls?: boolean
29
+ parallel_tool_calls?: boolean
30
+ dimensions?: boolean
31
+ }
32
+
33
+ interface ModelCapabilities {
34
+ family: string
35
+ limits: ModelLimits
36
+ object: string
37
+ supports: ModelSupports
38
+ tokenizer: string
39
+ type: string
40
+ }
41
+
42
+ export interface Model {
43
+ capabilities: ModelCapabilities
44
+ id: string
45
+ model_picker_enabled: boolean
46
+ name: string
47
+ object: string
48
+ preview: boolean
49
+ vendor: string
50
+ version: string
51
+ policy?: {
52
+ state: string
53
+ terms: string
54
+ }
55
+ }