Spaces:
Running
Running
Initial upload from Colab
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +13 -0
- .github/FUNDING.yml +15 -0
- .github/workflows/ci.yml +32 -0
- .github/workflows/deploy-pages.yml +40 -0
- .github/workflows/release-docker.yml +91 -0
- .github/workflows/release.yml +26 -0
- .gitignore +14 -0
- .vscode/settings.json +8 -0
- AGENTS.md +47 -0
- Dockerfile +25 -0
- LICENSE +21 -0
- README.md +348 -9
- bun.lock +0 -0
- entrypoint.sh +9 -0
- eslint.config.js +7 -0
- opencode.json +20 -0
- package.json +68 -0
- pages/index.html +556 -0
- src/auth.ts +52 -0
- src/check-usage.ts +58 -0
- src/debug.ts +127 -0
- src/lib/api-config.ts +52 -0
- src/lib/approval.ts +15 -0
- src/lib/error.ts +47 -0
- src/lib/paths.ts +26 -0
- src/lib/proxy.ts +66 -0
- src/lib/rate-limit.ts +46 -0
- src/lib/shell.ts +88 -0
- src/lib/state.ts +25 -0
- src/lib/token.ts +95 -0
- src/lib/tokenizer.ts +348 -0
- src/lib/utils.ts +26 -0
- src/main.ts +19 -0
- src/routes/chat-completions/handler.ts +68 -0
- src/routes/chat-completions/route.ts +15 -0
- src/routes/embeddings/route.ts +20 -0
- src/routes/messages/anthropic-types.ts +206 -0
- src/routes/messages/count-tokens-handler.ts +70 -0
- src/routes/messages/handler.ts +91 -0
- src/routes/messages/non-stream-translation.ts +357 -0
- src/routes/messages/route.ts +24 -0
- src/routes/messages/stream-translation.ts +190 -0
- src/routes/messages/utils.ts +16 -0
- src/routes/models/route.ts +34 -0
- src/routes/token/route.ts +16 -0
- src/routes/usage/route.ts +15 -0
- src/server.ts +31 -0
- src/services/copilot/create-chat-completions.ts +193 -0
- src/services/copilot/create-embeddings.ts +38 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: green
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: copilot-api
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](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 |
+
}
|