Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
run: bun install

- name: Run linter
run: bun run lint
run: bun run lint:all

- name: Run type check
run: bun run typecheck
Expand Down
91 changes: 91 additions & 0 deletions .github/workflows/release-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Docker Build and Push

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

on:
push:
# branches: [ "main" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
paths-ignore:
- 'docs/**'

env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
#IMAGE_NAME: ${{ github.repository }}


jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Set version
id: version
run: |
mkdir -p handlers
echo ${GITHUB_REF#refs/tags/v} > handlers/VERSION

# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@main
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: 'arm64,amd64'

# Workaround: https://github.com/docker/build-push-action/issues/461
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v2

# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
images: ${{ env.REGISTRY }}/${{ github.repository }}
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}

# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta.outputs.labels }}

8 changes: 3 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ EXPOSE 4141
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --spider -q http://localhost:4141/ || exit 1

ARG GH_TOKEN
ENV GH_TOKEN=$GH_TOKEN

ENTRYPOINT ["bun", "run", "dist/main.js"]
CMD ["start", "-g", "$GH_TOKEN"]
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
502 changes: 192 additions & 310 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/sh
if [ "$1" = "--auth" ]; then
# Run auth command
exec bun run dist/main.js auth
else
# Default command
exec bun run dist/main.js start -g "$GH_TOKEN" "$@"
fi

29 changes: 17 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "copilot-api",
"version": "0.5.14",
"version": "0.6.1",
"description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
"keywords": [
"proxy",
Expand All @@ -25,7 +25,8 @@
"build": "tsdown",
"dev": "bun run --watch ./src/main.ts",
"knip": "knip-bun",
"lint": "eslint . --cache",
"lint": "eslint --cache",
"lint:all": "eslint --cache .",
"prepack": "bun run build",
"prepare": "simple-git-hooks",
"release": "bumpp && bun publish --access public",
Expand All @@ -40,24 +41,28 @@
},
"dependencies": {
"citty": "^0.1.6",
"clipboardy": "^4.0.0",
"clipboardy": "^5.0.0",
"consola": "^3.4.2",
"fetch-event-stream": "^0.1.5",
"gpt-tokenizer": "^3.0.1",
"hono": "^4.9.6",
"srvx": "^0.8.7",
"tiny-invariant": "^1.3.3"
"hono": "^4.9.9",
"proxy-from-env": "^1.1.0",
"srvx": "^0.8.9",
"tiny-invariant": "^1.3.3",
"undici": "^7.16.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@echristian/eslint-config": "^0.0.54",
"@types/bun": "^1.2.21",
"@types/bun": "^1.2.23",
"@types/proxy-from-env": "^1.0.4",
"bumpp": "^10.2.3",
"eslint": "^9.35.0",
"knip": "^5.63.1",
"lint-staged": "^16.1.6",
"eslint": "^9.37.0",
"knip": "^5.64.1",
"lint-staged": "^16.2.3",
"prettier-plugin-packagejson": "^2.5.19",
"simple-git-hooks": "^2.13.1",
"tsdown": "^0.14.2",
"typescript": "^5.9.2"
"tsdown": "^0.15.6",
"typescript": "^5.9.3"
}
}
66 changes: 66 additions & 0 deletions src/lib/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import consola from "consola"
import { getProxyForUrl } from "proxy-from-env"
import { Agent, ProxyAgent, setGlobalDispatcher, type Dispatcher } from "undici"

export function initProxyFromEnv(): void {
if (typeof Bun !== "undefined") return

try {
const direct = new Agent()
const proxies = new Map<string, ProxyAgent>()

// We only need a minimal dispatcher that implements `dispatch` at runtime.
// Typing the object as `Dispatcher` forces TypeScript to require many
// additional methods. Instead, keep a plain object and cast when passing
// to `setGlobalDispatcher`.
const dispatcher = {
dispatch(
options: Dispatcher.DispatchOptions,
handler: Dispatcher.DispatchHandler,
) {
try {
const origin =
typeof options.origin === "string" ?
new URL(options.origin)
: (options.origin as URL)
const get = getProxyForUrl as unknown as (
u: string,
) => string | undefined
const raw = get(origin.toString())
const proxyUrl = raw && raw.length > 0 ? raw : undefined
if (!proxyUrl) {
consola.debug(`HTTP proxy bypass: ${origin.hostname}`)
return (direct as unknown as Dispatcher).dispatch(options, handler)
}
let agent = proxies.get(proxyUrl)
if (!agent) {
agent = new ProxyAgent(proxyUrl)
proxies.set(proxyUrl, agent)
}
let label = proxyUrl
try {
const u = new URL(proxyUrl)
label = `${u.protocol}//${u.host}`
} catch {
/* noop */
}
consola.debug(`HTTP proxy route: ${origin.hostname} via ${label}`)
return (agent as unknown as Dispatcher).dispatch(options, handler)
} catch {
return (direct as unknown as Dispatcher).dispatch(options, handler)
}
},
close() {
return direct.close()
},
destroy() {
return direct.destroy()
},
}

setGlobalDispatcher(dispatcher as unknown as Dispatcher)
consola.debug("HTTP proxy configured from environment (per-URL)")
} catch (err) {
consola.debug("Proxy setup skipped:", err)
}
}
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defineCommand, runMain } from "citty"
import { auth } from "./auth"
import { checkUsage } from "./check-usage"
import { debug } from "./debug"
import { initProxyFromEnv } from "./lib/proxy"
import { start } from "./start"

const main = defineCommand({
Expand All @@ -16,4 +17,6 @@ const main = defineCommand({
subCommands: { auth, start, "check-usage": checkUsage, debug },
})

initProxyFromEnv()

await runMain(main)
2 changes: 1 addition & 1 deletion src/services/get-vscode-version.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const FALLBACK = "1.98.1"
const FALLBACK = "1.104.3"

export async function getVSCodeVersion() {
const controller = new AbortController()
Expand Down
2 changes: 1 addition & 1 deletion tests/anthropic-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const chatCompletionRequestSchema = z.object({
messages: z.array(messageSchema).min(1, "Messages array cannot be empty."),
model: z.string(),
frequency_penalty: z.number().min(-2).max(2).optional().nullable(),
logit_bias: z.record(z.number()).optional().nullable(),
logit_bias: z.record(z.string(), z.number()).optional().nullable(),
logprobs: z.boolean().optional().nullable(),
top_logprobs: z.number().int().min(0).max(20).optional().nullable(),
max_tokens: z.number().int().optional().nullable(),
Expand Down
24 changes: 11 additions & 13 deletions tests/anthropic-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const anthropicContentBlockToolUseSchema = z.object({
type: z.literal("tool_use"),
id: z.string(),
name: z.string(),
input: z.record(z.any()),
input: z.record(z.string(), z.any()),
})

const anthropicMessageResponseSchema = z.object({
Expand Down Expand Up @@ -52,18 +52,16 @@ function isValidAnthropicResponse(payload: unknown): boolean {
return anthropicMessageResponseSchema.safeParse(payload).success
}

const anthropicStreamEventSchema = z
.object({
type: z.enum([
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]),
})
.passthrough()
const anthropicStreamEventSchema = z.looseObject({
type: z.enum([
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]),
})

function isValidAnthropicStreamEvent(payload: unknown): boolean {
return anthropicStreamEventSchema.safeParse(payload).success
Expand Down