Skip to content
Open
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
76 changes: 76 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Build OpenCode (patched fork)

on:
push:
branches:
- fix/copilot-business-endpoint-routing
workflow_dispatch:

jobs:
build:
runs-on: CustomRun
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Compute version from package.json
id: version
run: |
UPSTREAM_VER=$(python3 -c "import json; print(json.load(open('packages/opencode/package.json'))['version'])")
PATCHED_VER="${UPSTREAM_VER}-copilot-fix"
echo "upstream=$UPSTREAM_VER" >> "$GITHUB_OUTPUT"
echo "patched=$PATCHED_VER" >> "$GITHUB_OUTPUT"
echo "Version: upstream=$UPSTREAM_VER patched=$PATCHED_VER"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Install dependencies
run: bun install

- name: Build binary (linux-x64 only)
run: bun run script/build.ts --single
working-directory: packages/opencode
env:
OPENCODE_VERSION: "${{ steps.version.outputs.patched }}"

- name: Verify binary exists
run: |
ls -lh packages/opencode/dist/opencode-linux-x64/bin/opencode
file packages/opencode/dist/opencode-linux-x64/bin/opencode

- name: Smoke test — version
run: |
VERSION=$(packages/opencode/dist/opencode-linux-x64/bin/opencode --version 2>/dev/null || echo "FAILED")
echo "Version: $VERSION"
if [ "$VERSION" = "FAILED" ]; then
echo "ERROR: binary --version failed"
exit 1
fi

- name: Smoke test — patch signatures
run: |
BINARY="packages/opencode/dist/opencode-linux-x64/bin/opencode"
EXCHANGE=$(strings "$BINARY" | grep -c "exchangeCopilotToken" || true)
GHU_CHECK=$(strings "$BINARY" | grep -c "isGhuToken" || true)
VSCODE_ID=$(strings "$BINARY" | grep -c "Copilot-Integration-Id" || true)
echo "exchangeCopilotToken: $EXCHANGE"
echo "isGhuToken: $GHU_CHECK"
echo "Copilot-Integration-Id: $VSCODE_ID"
if [ "$EXCHANGE" -eq 0 ] || [ "$GHU_CHECK" -eq 0 ] || [ "$VSCODE_ID" -eq 0 ]; then
echo "ERROR: Patch signatures missing in binary!"
exit 1
fi
echo "All patch signatures verified."

- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: opencode-linux-x64-patched
path: packages/opencode/dist/opencode-linux-x64/bin/opencode
retention-days: 30
211 changes: 201 additions & 10 deletions packages/opencode/src/plugin/github-copilot/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,178 @@ function getUrls(domain: string) {
}
}

const DEFAULT_COPILOT_API_URL = "https://api.githubcopilot.com"

/**
* Detect whether an OAuth token is a GitHub App token (ghu_ prefix, issued by
* VS Code's client ID) vs an OAuth App token (gho_ prefix, issued by OpenCode's
* own client ID). ghu_ tokens require VS Code identity spoofing + bearer token
* exchange to work with the Copilot Business/Enterprise API.
*/
function isGhuToken(token: string): boolean {
return token.startsWith("ghu_")
}

/**
* VS Code identity headers. Required when using a ghu_ token (issued by VS
* Code's client ID Iv1.b507a08c87ecfe98). The Copilot API gates model access
* per OAuth client ID, and ghu_ tokens are bound to VS Code's client. Requests
* using these tokens must present matching identity headers or the API returns
* HTTP 400 "model not supported".
*
* Every third-party tool that works with Copilot Business (copilot.vim,
* avante.nvim, LiteLLM) sends these exact headers.
*/
const VSCODE_IDENTITY_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
}

/**
* Cache for the token exchange response from `copilot_internal/v2/token`.
* Stores both the API endpoint and the short-lived bearer token. The raw ghu_
* token cannot be used directly as Authorization — it must be exchanged for an
* HMAC-signed bearer token that the Copilot API actually accepts.
*
* Keyed by OAuth token prefix to support multiple accounts.
*/
let copilotTokenCache:
| {
tokenPrefix: string
apiEndpoint: string
bearerToken: string
expiresAt: number
}
| undefined

/**
* Exchange the OAuth token via `copilot_internal/v2/token` and cache both the
* plan-specific API endpoint and the short-lived bearer token.
*
* GitHub returns plan-specific endpoints (e.g. `api.business.githubcopilot.com`
* for Business users). The legacy unified endpoint `api.githubcopilot.com` is
* being deprecated (HTTP 466).
*
* For ghu_ tokens, the returned bearer token is mandatory — the Copilot API
* does not accept raw ghu_ tokens. For gho_ tokens, the raw token can be used
* directly but the endpoint discovery is still needed.
*
* The result is cached until the token's `expires_at` timestamp minus a 2-minute
* buffer to ensure refresh happens before expiry.
*/
async function exchangeCopilotToken(oauthToken: string, enterpriseDomain?: string): Promise<{
apiEndpoint: string
bearerToken: string
}> {
// Enterprise Server users have their own endpoint pattern
if (enterpriseDomain) {
return {
apiEndpoint: `https://copilot-api.${normalizeDomain(enterpriseDomain)}`,
bearerToken: oauthToken,
}
}

// Return cached result if still valid (with 2-minute early refresh buffer)
const prefix = oauthToken.slice(0, 8)
const REFRESH_BUFFER_MS = 2 * 60 * 1000
if (
copilotTokenCache &&
copilotTokenCache.tokenPrefix === prefix &&
Date.now() < copilotTokenCache.expiresAt - REFRESH_BUFFER_MS
) {
return {
apiEndpoint: copilotTokenCache.apiEndpoint,
bearerToken: copilotTokenCache.bearerToken,
}
}

// Use VS Code identity headers for ghu_ tokens, OpenCode identity for gho_
const userAgent = isGhuToken(oauthToken) ? VSCODE_IDENTITY_HEADERS["User-Agent"] : `opencode/${Installation.VERSION}`
const exchangeHeaders: Record<string, string> = {
Authorization: `token ${oauthToken}`,
Accept: "application/json",
"User-Agent": userAgent,
}
if (isGhuToken(oauthToken)) {
exchangeHeaders["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"]
exchangeHeaders["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"]
exchangeHeaders["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"]
}

try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: exchangeHeaders,
signal: AbortSignal.timeout(5_000),
})

if (response.ok) {
const data = (await response.json()) as {
token: string
expires_at?: number
endpoints?: {
api?: string
proxy?: string
telemetry?: string
"origin-tracker"?: string
}
}

const apiEndpoint = data.endpoints?.api || DEFAULT_COPILOT_API_URL
const bearerToken = data.token || oauthToken
const expiresAt = data.expires_at ? data.expires_at * 1000 : Date.now() + 25 * 60 * 1000

copilotTokenCache = {
tokenPrefix: prefix,
apiEndpoint,
bearerToken,
expiresAt,
}

log.info("copilot token exchange succeeded", {
endpoint: apiEndpoint,
tokenType: isGhuToken(oauthToken) ? "ghu" : "gho",
expiresIn: Math.round((expiresAt - Date.now()) / 1000) + "s",
})

return { apiEndpoint, bearerToken }
} else {
log.warn("copilot token exchange failed", {
status: response.status,
statusText: response.statusText,
})
}
} catch (error) {
log.warn("failed to exchange copilot token, using defaults", { error })
}

// Fallback: use raw token and default endpoint
return {
apiEndpoint: DEFAULT_COPILOT_API_URL,
bearerToken: oauthToken,
}
}

/**
* Legacy wrapper for backward compatibility — returns just the API endpoint.
*/
async function getCopilotApiEndpoint(oauthToken: string, enterpriseDomain?: string): Promise<string> {
const result = await exchangeCopilotToken(oauthToken, enterpriseDomain)
return result.apiEndpoint
}

/**
* Get the correct bearer token for API calls. For ghu_ tokens, this returns the
* exchanged short-lived bearer. For gho_ tokens, returns the raw token.
*/
async function getCopilotBearerToken(oauthToken: string, enterpriseDomain?: string): Promise<string> {
const result = await exchangeCopilotToken(oauthToken, enterpriseDomain)
return result.bearerToken
}

function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : DEFAULT_COPILOT_API_URL
}

function fix(model: Model, url: string): Model {
Expand All @@ -49,18 +219,26 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}

const auth = ctx.auth

const { apiEndpoint, bearerToken } = await exchangeCopilotToken(auth.refresh, auth.enterpriseUrl)
const modelHeaders: Record<string, string> = {
Authorization: `Bearer ${bearerToken}`,
"User-Agent": isGhuToken(auth.refresh)
? VSCODE_IDENTITY_HEADERS["User-Agent"]
: `opencode/${Installation.VERSION}`,
}
if (isGhuToken(auth.refresh)) {
modelHeaders["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"]
modelHeaders["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"]
modelHeaders["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"]
}
return CopilotModels.get(
base(auth.enterpriseUrl),
{
Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`,
},
apiEndpoint,
modelHeaders,
provider.models,
).catch((error) => {
log.error("failed to fetch copilot models", { error })
return Object.fromEntries(
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
Object.entries(provider.models).map(([id, model]) => [id, fix(model, apiEndpoint)]),
)
})
},
Expand All @@ -71,12 +249,17 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}


return {
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth()
if (info.type !== "oauth") return fetch(request, init)

// Exchange token on every fetch to ensure we have a fresh bearer
const { bearerToken } = await exchangeCopilotToken(info.refresh, info.enterpriseUrl)
const useVscodeIdentity = isGhuToken(info.refresh)

const url = request instanceof URL ? request.href : request.toString()
const { isVision, isAgent } = iife(() => {
try {
Expand Down Expand Up @@ -134,11 +317,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const headers: Record<string, string> = {
"x-initiator": isAgent ? "agent" : "user",
...(init?.headers as Record<string, string>),
"User-Agent": `opencode/${Installation.VERSION}`,
Authorization: `Bearer ${info.refresh}`,
"User-Agent": useVscodeIdentity
? VSCODE_IDENTITY_HEADERS["User-Agent"]
: `opencode/${Installation.VERSION}`,
Authorization: `Bearer ${bearerToken}`,
"Openai-Intent": "conversation-edits",
}

if (useVscodeIdentity) {
headers["Editor-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Version"]
headers["Editor-Plugin-Version"] = VSCODE_IDENTITY_HEADERS["Editor-Plugin-Version"]
headers["Copilot-Integration-Id"] = VSCODE_IDENTITY_HEADERS["Copilot-Integration-Id"]
}

if (isVision) {
headers["Copilot-Vision-Request"] = "true"
}
Expand Down
Loading