Skip to content

Commit e48e65b

Browse files
Handle Copilot auth
1 parent 03bb5e5 commit e48e65b

File tree

5 files changed

+234
-54
lines changed

5 files changed

+234
-54
lines changed

packages/opencode-mini/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
GITHUB_TOKEN_ALICE=
2+
GITHUB_TOKEN_BOB=

packages/opencode-mini/examples/basic.ts

Lines changed: 58 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,85 @@
55
*/
66
import { create, tool, Session } from "../src/index"
77

8-
// 1. Create an instance pointed at your project directory
9-
const mini = create({ directory: process.cwd() })
8+
// ---------------------------------------------------------------------------
9+
// Create an instance with copilot enabled
10+
// ---------------------------------------------------------------------------
1011

11-
// 2. Bootstrap opencode (loads plugins, DB, tools, etc.)
12+
const mini = create({ directory: process.cwd(), copilot: {} })
1213
await mini.init()
1314

14-
// 3. Create a session
15-
const session = await mini.session.create({ title: "My first session" })
16-
console.log("Created session:", session.id)
17-
18-
// 4. Send a message and get the assistant response
19-
const response = await mini.prompt({
20-
sessionID: session.id,
21-
parts: [{ type: "text", text: "What files are in the current directory?" }],
22-
})
23-
console.log("Assistant responded with", response.parts.length, "parts")
24-
25-
// 5. Retrieve conversation history
26-
const messages = await mini.session.messages(session.id)
27-
for (const msg of messages) {
28-
console.log(`[${msg.info.role}]`, msg.parts.length, "parts")
29-
}
30-
31-
// 6. List all sessions
32-
const all = await mini.session.list()
33-
console.log("Total sessions:", all.length)
34-
35-
// 7. Restore a session by ID
36-
const restored = await mini.session.get(session.id)
37-
console.log("Restored session:", restored.title)
38-
3915
// ---------------------------------------------------------------------------
40-
// Multi-tenant: per-user API credentials
16+
// Multi-tenant copilot: each user brings their own GitHub OAuth token
4117
// ---------------------------------------------------------------------------
4218

43-
// Register credentials for different users
4419
mini.credentials.set("user-alice", {
45-
providerID: "anthropic",
46-
apiKey: "sk-ant-alice-key",
20+
providerID: "copilot",
21+
token: "gho_alice-github-oauth-token",
4722
})
23+
4824
mini.credentials.set("user-bob", {
49-
providerID: "openai",
50-
apiKey: "sk-bob-key",
25+
providerID: "copilot",
26+
token: "gho_bob-github-oauth-token",
5127
})
5228

53-
// Each prompt specifies which user's credentials to use.
54-
// Credentials are scoped to the prompt lifetime only — different users
55-
// can take turns in the same session, each using their own API key.
56-
const shared = await mini.session.create({ title: "Shared session" })
29+
const session = await mini.session.create({ title: "Shared session" })
5730

58-
// Alice sends a message (uses her Anthropic key)
31+
// Alice sends a message routed through copilot with her token
5932
await mini.prompt({
60-
sessionID: shared.id,
33+
sessionID: session.id,
6134
parts: [{ type: "text", text: "Hello from Alice" }],
6235
userId: "user-alice",
63-
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
36+
model: { providerID: "copilot", modelID: "claude-sonnet-4-20250514" },
6437
})
6538

66-
// Bob continues the same conversation (uses his OpenAI key)
39+
// Bob continues the same conversation with his own token
6740
await mini.prompt({
68-
sessionID: shared.id,
41+
sessionID: session.id,
6942
parts: [{ type: "text", text: "Hello from Bob" }],
7043
userId: "user-bob",
71-
model: { providerID: "openai", modelID: "gpt-4o" },
44+
model: { providerID: "copilot", modelID: "gpt-4o" },
7245
})
7346

47+
// Retrieve conversation history
48+
const messages = await mini.session.messages(session.id)
49+
for (const msg of messages) {
50+
console.log(`[${msg.info.role}]`, msg.parts.length, "parts")
51+
}
52+
7453
// Remove credentials when a user logs out
7554
mini.credentials.remove("user-alice")
7655

56+
// ---------------------------------------------------------------------------
57+
// API-key providers work too (Anthropic, OpenAI, etc.)
58+
// ---------------------------------------------------------------------------
59+
60+
mini.credentials.set("user-carol", {
61+
providerID: "anthropic",
62+
token: "sk-ant-carol-key",
63+
})
64+
65+
await mini.prompt({
66+
sessionID: session.id,
67+
parts: [{ type: "text", text: "Hello from Carol" }],
68+
userId: "user-carol",
69+
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
70+
})
71+
72+
// ---------------------------------------------------------------------------
73+
// Custom copilot model list
74+
// ---------------------------------------------------------------------------
75+
76+
const custom = create({
77+
directory: process.cwd(),
78+
copilot: {
79+
provider: "my-copilot",
80+
models: {
81+
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4", limit: { context: 200000, output: 16384 } },
82+
"gpt-4o": { name: "GPT-4o", limit: { context: 128000, output: 16384 } },
83+
},
84+
},
85+
})
86+
7787
// ---------------------------------------------------------------------------
7888
// Custom tools
7989
// ---------------------------------------------------------------------------
@@ -90,35 +100,32 @@ await mini.tools.register(
90100
)
91101

92102
// ---------------------------------------------------------------------------
93-
// Event subscription
103+
// Events
94104
// ---------------------------------------------------------------------------
95105

96-
// Subscribe to all events (raw bus)
97106
const unsub = await mini.subscribeAll((event) => {
98107
console.log("Event:", event.type)
99108
})
100109

101-
// Subscribe to specific events
102110
await mini.subscribe(Session.Event.Created, (event) => {
103111
console.log("Session created:", event.properties.info.id)
104112
})
105113

106-
// Unsubscribe when done
107114
unsub()
108115

109116
// ---------------------------------------------------------------------------
110-
// Cancel an in-progress prompt
117+
// Cancel
111118
// ---------------------------------------------------------------------------
112119

113120
const long = await mini.session.create({ title: "Cancellable" })
114121

115-
// Start a prompt in the background
116122
const pending = mini.prompt({
117123
sessionID: long.id,
118124
parts: [{ type: "text", text: "Write a very long essay about the history of computing" }],
125+
userId: "user-bob",
126+
model: { providerID: "copilot", modelID: "gpt-4o" },
119127
})
120128

121-
// Cancel it after 2 seconds
122129
setTimeout(() => mini.cancel(long.id), 2000)
123130
await pending.catch(() => console.log("Prompt cancelled"))
124131

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { create, tool, Session } from "../src/index"
2+
import { env } from "bun"
3+
4+
const mini = create({ directory: process.cwd(), copilot: {}, logLevel: "ERROR" })
5+
await mini.init()
6+
7+
mini.credentials.set("user-alice", {
8+
providerID: "copilot",
9+
token: env.GITHUB_TOKEN_ALICE ?? "",
10+
})
11+
12+
mini.credentials.set("user-bob", {
13+
providerID: "copilot",
14+
token: env.GITHUB_TOKEN_BOB ?? "",
15+
})
16+
17+
console.log("[!] Creating session...")
18+
const session = await mini.session.create({ title: "Shared session" })
19+
20+
console.log("[!] Alice sends a message...")
21+
let replyA1 = await mini.prompt({
22+
sessionID: session.id,
23+
parts: [{ type: "text", text: "Hello from Alice" }],
24+
userId: "user-alice",
25+
model: { providerID: "copilot", modelID: "claude-opus-4.6" },
26+
})
27+
console.log(replyA1)
28+
29+
console.log("[!] Bob sends a message...")
30+
let replyB1 = await mini.prompt({
31+
sessionID: session.id,
32+
parts: [{ type: "text", text: "Hello from Bob" }],
33+
userId: "user-bob",
34+
model: { providerID: "copilot", modelID: "claude-opus-4.6" },
35+
})
36+
console.log(replyB1)
37+
38+
console.log("[!] Alice asks who's in the conversation...")
39+
let replyA2 = await mini.prompt({
40+
sessionID: session.id,
41+
parts: [{ type: "text", text: "Who is part of this conversation?" }],
42+
userId: "user-alice",
43+
model: { providerID: "copilot", modelID: "claude-opus-4.6" },
44+
})
45+
console.log(replyA2)
46+
47+
// console.log("[!] Retrieving conversation history...")
48+
// const messages = await mini.session.messages(session.id)
49+
// for (const msg of messages) {
50+
// console.log(`[${msg.info.role}]`, msg)
51+
// }
52+
53+
await mini.dispose()
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Installation } from "opencode/installation"
2+
import { ModelsDev } from "opencode/provider/models"
3+
4+
const TOKEN_HEADER = "x-copilot-token"
5+
const BASE_URL = "https://api.githubcopilot.com"
6+
7+
function detect(url: string, init?: RequestInit) {
8+
try {
9+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
10+
11+
// Completions API
12+
if (body?.messages && url.includes("completions")) {
13+
const last = body.messages[body.messages.length - 1]
14+
return {
15+
vision: body.messages.some(
16+
(msg: any) => Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
17+
),
18+
agent: last?.role !== "user",
19+
}
20+
}
21+
22+
// Responses API
23+
if (body?.input) {
24+
const last = body.input[body.input.length - 1]
25+
return {
26+
vision: body.input.some(
27+
(item: any) => Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
28+
),
29+
agent: last?.role !== "user",
30+
}
31+
}
32+
33+
// Messages API
34+
if (body?.messages) {
35+
const last = body.messages[body.messages.length - 1]
36+
const hasNonTool = Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result")
37+
return {
38+
vision: body.messages.some(
39+
(item: any) =>
40+
Array.isArray(item?.content) &&
41+
item.content.some(
42+
(part: any) =>
43+
part?.type === "image" ||
44+
(part?.type === "tool_result" &&
45+
Array.isArray(part?.content) &&
46+
part.content.some((nested: any) => nested?.type === "image")),
47+
),
48+
),
49+
agent: !(last?.role === "user" && hasNonTool),
50+
}
51+
}
52+
} catch {}
53+
return { vision: false, agent: false }
54+
}
55+
56+
export async function copilotFetch(request: RequestInfo | URL, init?: RequestInit) {
57+
const incoming = (init?.headers ?? {}) as Record<string, string>
58+
const token = incoming[TOKEN_HEADER]
59+
if (!token) return fetch(request, init)
60+
61+
const url = request instanceof URL ? request.href : request.toString()
62+
const { vision, agent } = detect(url, init)
63+
64+
const headers: Record<string, string> = {
65+
"x-initiator": agent ? "agent" : "user",
66+
...incoming,
67+
"User-Agent": `opencode/${Installation.VERSION}`,
68+
Authorization: `Bearer ${token}`,
69+
"Openai-Intent": "conversation-edits",
70+
}
71+
if (vision) headers["Copilot-Vision-Request"] = "true"
72+
73+
delete headers[TOKEN_HEADER]
74+
delete headers["x-api-key"]
75+
delete headers["authorization"]
76+
77+
return fetch(request, { ...init, headers })
78+
}
79+
80+
export { TOKEN_HEADER, BASE_URL }
81+
82+
export async function models() {
83+
const data = await ModelsDev.get()
84+
return data["github-copilot"]?.models ?? {}
85+
}

packages/opencode-mini/src/index.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,28 @@ import { Bus } from "opencode/bus"
55
import { Instance } from "opencode/project/instance"
66
import { InstanceBootstrap } from "opencode/project/bootstrap"
77
import { Plugin } from "opencode/plugin"
8+
import { Config } from "opencode/config/config"
89
import type { MessageV2 } from "opencode/session/message-v2"
910
import type { BusEvent } from "opencode/bus/bus-event"
11+
import { Log } from "opencode/util/log"
12+
import { copilotFetch, TOKEN_HEADER, BASE_URL, models as copilotModels } from "./copilot"
1013

1114
type Credentials = {
1215
providerID: string
13-
apiKey: string
16+
token: string
1417
}
1518

16-
export function create(opts: { directory: string }) {
19+
type CopilotConfig = {
20+
provider?: string
21+
models?: Record<string, Record<string, any>>
22+
}
23+
24+
export function create(opts: { directory: string; copilot?: CopilotConfig; logLevel?: Log.Level }) {
1725
const dir = opts.directory
1826
const creds = new Map<string, Credentials>()
1927
const sessions = new Map<string, string>()
28+
const copilot = opts.copilot
29+
const copilotID = copilot?.provider ?? "copilot"
2030
let ready = false
2131

2232
function wrap<T>(fn: () => T) {
@@ -29,7 +39,26 @@ export function create(opts: { directory: string }) {
2939

3040
async function init() {
3141
if (ready) return
42+
await Log.init({ print: true, level: opts.logLevel ?? "ERROR" })
3243
await wrap(async () => {
44+
// Register copilot provider via config mutation before Provider.state() runs
45+
if (copilot !== undefined) {
46+
const config = await Config.get()
47+
const fetched = await copilotModels()
48+
const merged = { ...fetched, ...(copilot.models ?? {}) }
49+
config.provider = config.provider ?? {}
50+
config.provider[copilotID] = {
51+
name: "Copilot",
52+
npm: "@ai-sdk/github-copilot",
53+
models: merged,
54+
options: {
55+
apiKey: "",
56+
baseURL: BASE_URL,
57+
fetch: copilotFetch,
58+
},
59+
}
60+
}
61+
3362
const hooks = await Plugin.list()
3463
hooks.push({
3564
"chat.headers": async (input, output) => {
@@ -38,7 +67,11 @@ export function create(opts: { directory: string }) {
3867
const cred = creds.get(uid)
3968
if (!cred) return
4069
if (cred.providerID !== input.model.providerID) return
41-
output.headers["Authorization"] = "Bearer " + cred.apiKey
70+
if (cred.providerID === copilotID) {
71+
output.headers[TOKEN_HEADER] = cred.token
72+
} else {
73+
output.headers["Authorization"] = "Bearer " + cred.token
74+
}
4275
},
4376
})
4477
})

0 commit comments

Comments
 (0)