Spaces:
Running
Running
Initial upload from Google 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 +23 -0
- LICENSE +21 -0
- bun.lock +0 -0
- entrypoint.sh +12 -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
- src/services/get-vscode-version.ts +33 -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,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 7860
|
| 19 |
+
|
| 20 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 21 |
+
RUN chmod +x /entrypoint.sh
|
| 22 |
+
|
| 23 |
+
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.
|
bun.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
PORT=${PORT:-7860}
|
| 4 |
+
|
| 5 |
+
if [ "$1" = "--auth" ]; then
|
| 6 |
+
exec bun run dist/main.js auth
|
| 7 |
+
else
|
| 8 |
+
exec bun run dist/main.js start \
|
| 9 |
+
-g "$GH_TOKEN" \
|
| 10 |
+
--port $PORT \
|
| 11 |
+
--host 0.0.0.0
|
| 12 |
+
fi
|
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 <erickchristian48@gmail.com>",
|
| 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/lucide-react@0.378.0/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 |
+
}
|
src/services/get-vscode-version.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const FALLBACK = "1.104.3"
|
| 2 |
+
|
| 3 |
+
export async function getVSCodeVersion() {
|
| 4 |
+
const controller = new AbortController()
|
| 5 |
+
const timeout = setTimeout(() => {
|
| 6 |
+
controller.abort()
|
| 7 |
+
}, 5000)
|
| 8 |
+
|
| 9 |
+
try {
|
| 10 |
+
const response = await fetch(
|
| 11 |
+
"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=visual-studio-code-bin",
|
| 12 |
+
{
|
| 13 |
+
signal: controller.signal,
|
| 14 |
+
},
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
const pkgbuild = await response.text()
|
| 18 |
+
const pkgverRegex = /pkgver=([0-9.]+)/
|
| 19 |
+
const match = pkgbuild.match(pkgverRegex)
|
| 20 |
+
|
| 21 |
+
if (match) {
|
| 22 |
+
return match[1]
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return FALLBACK
|
| 26 |
+
} catch {
|
| 27 |
+
return FALLBACK
|
| 28 |
+
} finally {
|
| 29 |
+
clearTimeout(timeout)
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
await getVSCodeVersion()
|