Skip to content

Commit ca803ba

Browse files
committed
feat: implement opencode OAuth handling
1 parent 8374c20 commit ca803ba

12 files changed

Lines changed: 267 additions & 74 deletions

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ A reverse-engineered proxy for the GitHub Copilot API that exposes it as an Open
3939
- **Token Visibility**: Option to display GitHub and Copilot tokens during authentication and refresh for debugging (`--show-token`).
4040
- **Flexible Authentication**: Authenticate interactively or provide a GitHub token directly, suitable for CI/CD environments.
4141
- **Support for Different Account Types**: Works with individual, business, and enterprise GitHub Copilot plans.
42+
- **Opencode OAuth Support**: Use opencode GitHub Copilot authentication by setting `COPILOT_API_OAUTH_APP=opencode` environment variable.
4243

4344
## Demo
4445

@@ -302,6 +303,28 @@ npx copilot-api@latest debug --json
302303

303304
# Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.)
304305
npx copilot-api@latest start --proxy-env
306+
307+
# Use opencode GitHub Copilot authentication
308+
COPILOT_API_OAUTH_APP=opencode npx @jeffreycao/copilot-api@latest start
309+
```
310+
311+
### Opencode OAuth Authentication
312+
313+
You can use opencode GitHub Copilot authentication instead of the default one:
314+
315+
```sh
316+
# Set environment variable before running any command
317+
export COPILOT_API_OAUTH_APP=opencode
318+
319+
# Then run start or auth commands
320+
npx @jeffreycao/copilot-api@latest start
321+
npx @jeffreycao/copilot-api@latest auth
322+
```
323+
324+
Or use inline environment variable:
325+
326+
```sh
327+
COPILOT_API_OAUTH_APP=opencode npx @jeffreycao/copilot-api@latest start
305328
```
306329

307330
## Using the Usage Viewer

src/lib/api-config.ts

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,98 @@ import { randomUUID } from "node:crypto"
22

33
import type { State } from "./state"
44

5+
export const isOpencodeOauthApp = (): boolean => {
6+
return process.env.COPILOT_API_OAUTH_APP === "opencode"
7+
}
8+
9+
export const normalizeDomain = (input: string): string => {
10+
return input
11+
.trim()
12+
.replace(/^https?:\/\//u, "")
13+
.replace(/\/+$/u, "")
14+
}
15+
16+
export const getEnterpriseDomain = (): string | null => {
17+
const raw = (process.env.COPILOT_API_ENTERPRISE_URL ?? "").trim()
18+
if (!raw) return null
19+
const normalized = normalizeDomain(raw)
20+
return normalized || null
21+
}
22+
23+
export const getGitHubBaseUrl = (): string => {
24+
const resolvedDomain = getEnterpriseDomain()
25+
return resolvedDomain ? `https://${resolvedDomain}` : GITHUB_BASE_URL
26+
}
27+
28+
export const getGitHubApiBaseUrl = (): string => {
29+
const resolvedDomain = getEnterpriseDomain()
30+
return resolvedDomain ?
31+
`https://${resolvedDomain}/api/v3`
32+
: GITHUB_API_BASE_URL
33+
}
34+
35+
export const getOpencodeOauthHeaders = (): Record<string, string> => {
36+
return {
37+
Accept: "application/json",
38+
"Content-Type": "application/json",
39+
"User-Agent":
40+
"opencode/1.2.16 ai-sdk/provider-utils/3.0.21 runtime/bun/1.3.10, opencode/1.2.16",
41+
}
42+
}
43+
44+
export const getOauthUrls = (): {
45+
deviceCodeUrl: string
46+
accessTokenUrl: string
47+
} => {
48+
const githubBaseUrl = getGitHubBaseUrl()
49+
50+
return {
51+
deviceCodeUrl: `${githubBaseUrl}/login/device/code`,
52+
accessTokenUrl: `${githubBaseUrl}/login/oauth/access_token`,
53+
}
54+
}
55+
56+
interface OauthAppConfig {
57+
clientId: string
58+
headers: Record<string, string>
59+
scope: string
60+
}
61+
62+
export const getOauthAppConfig = (): OauthAppConfig => {
63+
if (isOpencodeOauthApp()) {
64+
return {
65+
clientId: OPENCODE_GITHUB_CLIENT_ID,
66+
headers: getOpencodeOauthHeaders(),
67+
scope: GITHUB_APP_SCOPES,
68+
}
69+
}
70+
71+
return {
72+
clientId: GITHUB_CLIENT_ID,
73+
headers: standardHeaders(),
74+
scope: GITHUB_APP_SCOPES,
75+
}
76+
}
77+
78+
export const prepareInteractionHeaders = (
79+
sessionId: string | undefined,
80+
isSubagent: boolean,
81+
headers: Record<string, string>,
82+
) => {
83+
const sendInteractionHeaders = !isOpencodeOauthApp()
84+
85+
if (isSubagent) {
86+
headers["x-initiator"] = "agent"
87+
if (sendInteractionHeaders) {
88+
headers["x-interaction-type"] = "conversation-subagent"
89+
}
90+
}
91+
92+
if (sessionId && sendInteractionHeaders) {
93+
headers["x-interaction-id"] = sessionId
94+
}
95+
}
96+
597
export const standardHeaders = () => ({
698
"content-type": "application/json",
799
accept: "application/json",
@@ -13,15 +105,34 @@ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`
13105

14106
const API_VERSION = "2025-10-01"
15107

16-
export const copilotBaseUrl = (state: State) =>
17-
state.accountType === "individual" ?
18-
"https://api.githubcopilot.com"
19-
: `https://api.${state.accountType}.githubcopilot.com`
108+
export const copilotBaseUrl = (state: State) => {
109+
const enterpriseDomain = getEnterpriseDomain()
110+
if (enterpriseDomain) {
111+
return `https://copilot-api.${enterpriseDomain}`
112+
}
113+
114+
return state.accountType === "individual" ?
115+
"https://api.githubcopilot.com"
116+
: `https://api.${state.accountType}.githubcopilot.com`
117+
}
118+
20119
export const copilotHeaders = (
21120
state: State,
22121
requestId?: string,
23122
vision: boolean = false,
24123
) => {
124+
if (isOpencodeOauthApp()) {
125+
const headers: Record<string, string> = {
126+
Authorization: `Bearer ${state.copilotToken}`,
127+
...getOpencodeOauthHeaders(),
128+
"Openai-Intent": "conversation-edits",
129+
}
130+
131+
if (vision) headers["Copilot-Vision-Request"] = "true"
132+
133+
return headers
134+
}
135+
25136
const requestIdValue = requestId ?? randomUUID()
26137
const headers: Record<string, string> = {
27138
Authorization: `Bearer ${state.copilotToken}`,
@@ -65,3 +176,4 @@ export const githubHeaders = (state: State) => ({
65176
export const GITHUB_BASE_URL = "https://github.com"
66177
export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
67178
export const GITHUB_APP_SCOPES = ["read:user"].join(" ")
179+
export const OPENCODE_GITHUB_CLIENT_ID = "Ov23li8tweQw6odWQebz"

src/lib/paths.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import fs from "node:fs/promises"
22
import os from "node:os"
33
import path from "node:path"
44

5-
const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")
5+
const AUTH_APP = process.env.COPILOT_API_OAUTH_APP || ""
6+
const ENTERPRISE_PREFIX = process.env.COPILOT_API_ENTERPRISE_URL ? "ent_" : ""
67

7-
const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token")
8+
const DEFAULT_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")
9+
const APP_DIR = process.env.COPILOT_API_HOME || DEFAULT_DIR
10+
11+
const GITHUB_TOKEN_PATH = path.join(
12+
APP_DIR,
13+
AUTH_APP,
14+
ENTERPRISE_PREFIX + "github_token",
15+
)
816
const CONFIG_PATH = path.join(APP_DIR, "config.json")
917

1018
export const PATHS = {
@@ -14,7 +22,7 @@ export const PATHS = {
1422
}
1523

1624
export async function ensurePaths(): Promise<void> {
17-
await fs.mkdir(PATHS.APP_DIR, { recursive: true })
25+
await fs.mkdir(path.join(PATHS.APP_DIR, AUTH_APP), { recursive: true })
1826
await ensureFile(PATHS.GITHUB_TOKEN_PATH)
1927
await ensureFile(PATHS.CONFIG_PATH)
2028
}

src/lib/token.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import consola from "consola"
22
import fs from "node:fs/promises"
3+
import { setTimeout as delay } from "node:timers/promises"
34

5+
import { isOpencodeOauthApp } from "~/lib/api-config"
46
import { PATHS } from "~/lib/paths"
57
import { getCopilotToken } from "~/services/github/get-copilot-token"
68
import { getDeviceCode } from "~/services/github/get-device-code"
@@ -10,12 +12,37 @@ import { pollAccessToken } from "~/services/github/poll-access-token"
1012
import { HTTPError } from "./error"
1113
import { state } from "./state"
1214

15+
let copilotRefreshLoopController: AbortController | null = null
16+
17+
export const stopCopilotRefreshLoop = () => {
18+
if (!copilotRefreshLoopController) {
19+
return
20+
}
21+
22+
copilotRefreshLoopController.abort()
23+
copilotRefreshLoopController = null
24+
}
25+
1326
const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
1427

1528
const writeGithubToken = (token: string) =>
1629
fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)
1730

1831
export const setupCopilotToken = async () => {
32+
if (isOpencodeOauthApp()) {
33+
if (!state.githubToken) throw new Error(`opencode token not found`)
34+
35+
state.copilotToken = state.githubToken
36+
37+
consola.debug("GitHub Copilot token set from opencode auth token")
38+
if (state.showToken) {
39+
consola.info("Copilot token:", state.copilotToken)
40+
}
41+
42+
stopCopilotRefreshLoop()
43+
return
44+
}
45+
1946
const { token, refresh_in } = await getCopilotToken()
2047
state.copilotToken = token
2148

@@ -25,21 +52,48 @@ export const setupCopilotToken = async () => {
2552
consola.info("Copilot token:", token)
2653
}
2754

28-
const refreshInterval = (refresh_in - 60) * 1000
29-
setInterval(async () => {
55+
stopCopilotRefreshLoop()
56+
57+
const controller = new AbortController()
58+
copilotRefreshLoopController = controller
59+
60+
runCopilotRefreshLoop(refresh_in, controller.signal)
61+
.catch(() => {
62+
consola.warn("Copilot token refresh loop stopped")
63+
})
64+
.finally(() => {
65+
if (copilotRefreshLoopController === controller) {
66+
copilotRefreshLoopController = null
67+
}
68+
})
69+
}
70+
71+
const runCopilotRefreshLoop = async (
72+
refreshIn: number,
73+
signal: AbortSignal,
74+
) => {
75+
let nextRefreshDelayMs = (refreshIn - 60) * 1000
76+
77+
while (!signal.aborted) {
78+
await delay(nextRefreshDelayMs, undefined, { signal })
79+
3080
consola.debug("Refreshing Copilot token")
81+
3182
try {
32-
const { token } = await getCopilotToken()
83+
const { token, refresh_in } = await getCopilotToken()
3384
state.copilotToken = token
3485
consola.debug("Copilot token refreshed")
3586
if (state.showToken) {
3687
consola.info("Refreshed Copilot token:", token)
3788
}
89+
90+
nextRefreshDelayMs = (refresh_in - 60) * 1000
3891
} catch (error) {
3992
consola.error("Failed to refresh Copilot token:", error)
40-
throw error
93+
nextRefreshDelayMs = 15_000
94+
consola.warn(`Retrying Copilot token refresh in ${nextRefreshDelayMs}ms`)
4195
}
42-
}, refreshInterval)
96+
}
4397
}
4498

4599
interface SetupGitHubTokenOptions {

src/services/copilot/create-chat-completions.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { events } from "fetch-event-stream"
33

44
import type { SubagentMarker } from "~/routes/messages/subagent-marker"
55

6-
import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
6+
import {
7+
copilotBaseUrl,
8+
copilotHeaders,
9+
prepareInteractionHeaders,
10+
} from "~/lib/api-config"
711
import { HTTPError } from "~/lib/error"
812
import { state } from "~/lib/state"
913

@@ -40,14 +44,11 @@ export const createChatCompletions = async (
4044
"x-initiator": isAgentCall ? "agent" : "user",
4145
}
4246

43-
if (options.subagentMarker) {
44-
headers["x-initiator"] = "agent"
45-
headers["x-interaction-type"] = "conversation-subagent"
46-
}
47-
48-
if (options.sessionId) {
49-
headers["x-interaction-id"] = options.sessionId
50-
}
47+
prepareInteractionHeaders(
48+
options.sessionId,
49+
Boolean(options.subagentMarker),
50+
headers,
51+
)
5152

5253
const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {
5354
method: "POST",

src/services/copilot/create-messages.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type {
77
} from "~/routes/messages/anthropic-types"
88
import type { SubagentMarker } from "~/routes/messages/subagent-marker"
99

10-
import { copilotBaseUrl, copilotHeaders } from "~/lib/api-config"
10+
import {
11+
copilotBaseUrl,
12+
copilotHeaders,
13+
prepareInteractionHeaders,
14+
} from "~/lib/api-config"
1115
import { HTTPError } from "~/lib/error"
1216
import { state } from "~/lib/state"
1317

@@ -84,14 +88,11 @@ export const createMessages = async (
8488
"x-initiator": isInitiateRequest ? "user" : "agent",
8589
}
8690

87-
if (options.subagentMarker) {
88-
headers["x-initiator"] = "agent"
89-
headers["x-interaction-type"] = "conversation-subagent"
90-
}
91-
92-
if (options.sessionId) {
93-
headers["x-interaction-id"] = options.sessionId
94-
}
91+
prepareInteractionHeaders(
92+
options.sessionId,
93+
Boolean(options.subagentMarker),
94+
headers,
95+
)
9596

9697
// align with vscode copilot extension anthropic-beta
9798
const anthropicBeta = buildAnthropicBetaHeader(

0 commit comments

Comments
 (0)