From 03bb5e598a9e29f7fdb40fcdc3fdfa7e80b6cd26 Mon Sep 17 00:00:00 2001 From: Krzysztof Cieslak Date: Wed, 11 Mar 2026 00:40:28 +0100 Subject: [PATCH 1/2] Initial version --- bun.lock | 20 +++- packages/opencode-mini/examples/basic.ts | 129 +++++++++++++++++++++ packages/opencode-mini/package.json | 23 ++++ packages/opencode-mini/src/index.ts | 137 +++++++++++++++++++++++ packages/opencode-mini/tsconfig.json | 14 +++ 5 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 packages/opencode-mini/examples/basic.ts create mode 100644 packages/opencode-mini/package.json create mode 100644 packages/opencode-mini/src/index.ts create mode 100644 packages/opencode-mini/tsconfig.json diff --git a/bun.lock b/bun.lock index 1721ba330b3..fb75462493a 100644 --- a/bun.lock +++ b/bun.lock @@ -411,6 +411,20 @@ "zod-to-json-schema": "3.24.5", }, }, + "packages/opencode-mini": { + "name": "@opencode-ai/opencode-mini", + "version": "0.0.1", + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "opencode": "workspace:*", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/plugin": { "name": "@opencode-ai/plugin", "version": "1.2.24", @@ -1413,6 +1427,8 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/opencode-mini": ["@opencode-ai/opencode-mini@workspace:packages/opencode-mini"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -1887,7 +1903,7 @@ "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }], "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], @@ -3003,7 +3019,7 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/packages/opencode-mini/examples/basic.ts b/packages/opencode-mini/examples/basic.ts new file mode 100644 index 00000000000..f718d720fb2 --- /dev/null +++ b/packages/opencode-mini/examples/basic.ts @@ -0,0 +1,129 @@ +/** + * opencode-mini usage examples + * + * Run with: bun run packages/opencode-mini/examples/basic.ts + */ +import { create, tool, Session } from "../src/index" + +// 1. Create an instance pointed at your project directory +const mini = create({ directory: process.cwd() }) + +// 2. Bootstrap opencode (loads plugins, DB, tools, etc.) +await mini.init() + +// 3. Create a session +const session = await mini.session.create({ title: "My first session" }) +console.log("Created session:", session.id) + +// 4. Send a message and get the assistant response +const response = await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "What files are in the current directory?" }], +}) +console.log("Assistant responded with", response.parts.length, "parts") + +// 5. Retrieve conversation history +const messages = await mini.session.messages(session.id) +for (const msg of messages) { + console.log(`[${msg.info.role}]`, msg.parts.length, "parts") +} + +// 6. List all sessions +const all = await mini.session.list() +console.log("Total sessions:", all.length) + +// 7. Restore a session by ID +const restored = await mini.session.get(session.id) +console.log("Restored session:", restored.title) + +// --------------------------------------------------------------------------- +// Multi-tenant: per-user API credentials +// --------------------------------------------------------------------------- + +// Register credentials for different users +mini.credentials.set("user-alice", { + providerID: "anthropic", + apiKey: "sk-ant-alice-key", +}) +mini.credentials.set("user-bob", { + providerID: "openai", + apiKey: "sk-bob-key", +}) + +// Each prompt specifies which user's credentials to use. +// Credentials are scoped to the prompt lifetime only — different users +// can take turns in the same session, each using their own API key. +const shared = await mini.session.create({ title: "Shared session" }) + +// Alice sends a message (uses her Anthropic key) +await mini.prompt({ + sessionID: shared.id, + parts: [{ type: "text", text: "Hello from Alice" }], + userId: "user-alice", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, +}) + +// Bob continues the same conversation (uses his OpenAI key) +await mini.prompt({ + sessionID: shared.id, + parts: [{ type: "text", text: "Hello from Bob" }], + userId: "user-bob", + model: { providerID: "openai", modelID: "gpt-4o" }, +}) + +// Remove credentials when a user logs out +mini.credentials.remove("user-alice") + +// --------------------------------------------------------------------------- +// Custom tools +// --------------------------------------------------------------------------- + +await mini.tools.register( + "current_time", + tool({ + description: "Returns the current date and time", + args: {}, + async execute() { + return new Date().toISOString() + }, + }), +) + +// --------------------------------------------------------------------------- +// Event subscription +// --------------------------------------------------------------------------- + +// Subscribe to all events (raw bus) +const unsub = await mini.subscribeAll((event) => { + console.log("Event:", event.type) +}) + +// Subscribe to specific events +await mini.subscribe(Session.Event.Created, (event) => { + console.log("Session created:", event.properties.info.id) +}) + +// Unsubscribe when done +unsub() + +// --------------------------------------------------------------------------- +// Cancel an in-progress prompt +// --------------------------------------------------------------------------- + +const long = await mini.session.create({ title: "Cancellable" }) + +// Start a prompt in the background +const pending = mini.prompt({ + sessionID: long.id, + parts: [{ type: "text", text: "Write a very long essay about the history of computing" }], +}) + +// Cancel it after 2 seconds +setTimeout(() => mini.cancel(long.id), 2000) +await pending.catch(() => console.log("Prompt cancelled")) + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +await mini.dispose() diff --git a/packages/opencode-mini/package.json b/packages/opencode-mini/package.json new file mode 100644 index 00000000000..41aeefcdf42 --- /dev/null +++ b/packages/opencode-mini/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/opencode-mini", + "version": "0.0.1", + "type": "module", + "license": "MIT", + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "opencode": "workspace:*", + "@opencode-ai/plugin": "workspace:*" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/opencode-mini/src/index.ts b/packages/opencode-mini/src/index.ts new file mode 100644 index 00000000000..22697a05a15 --- /dev/null +++ b/packages/opencode-mini/src/index.ts @@ -0,0 +1,137 @@ +import type { Hooks, ToolDefinition } from "@opencode-ai/plugin" +import { Session } from "opencode/session" +import { SessionPrompt } from "opencode/session/prompt" +import { Bus } from "opencode/bus" +import { Instance } from "opencode/project/instance" +import { InstanceBootstrap } from "opencode/project/bootstrap" +import { Plugin } from "opencode/plugin" +import type { MessageV2 } from "opencode/session/message-v2" +import type { BusEvent } from "opencode/bus/bus-event" + +type Credentials = { + providerID: string + apiKey: string +} + +export function create(opts: { directory: string }) { + const dir = opts.directory + const creds = new Map() + const sessions = new Map() + let ready = false + + function wrap(fn: () => T) { + return Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn, + }) + } + + async function init() { + if (ready) return + await wrap(async () => { + const hooks = await Plugin.list() + hooks.push({ + "chat.headers": async (input, output) => { + const uid = sessions.get(input.sessionID) + if (!uid) return + const cred = creds.get(uid) + if (!cred) return + if (cred.providerID !== input.model.providerID) return + output.headers["Authorization"] = "Bearer " + cred.apiKey + }, + }) + }) + ready = true + } + + return { + init, + + credentials: { + set(id: string, value: Credentials) { + creds.set(id, value) + }, + remove(id: string) { + creds.delete(id) + }, + }, + + session: { + create(input?: { parentID?: string; title?: string }) { + return wrap(() => Session.create(input)) + }, + get(id: string) { + return wrap(() => Session.get(id)) + }, + list() { + return wrap(() => [...Session.list()]) + }, + messages(id: string, limit?: number) { + return wrap(() => Session.messages({ sessionID: id, limit })) + }, + }, + + async prompt(input: { + sessionID: string + parts: SessionPrompt.PromptInput["parts"] + model?: { providerID: string; modelID: string } + userId?: string + system?: string + agent?: string + }) { + if (input.userId) sessions.set(input.sessionID, input.userId) + try { + return await wrap(() => + SessionPrompt.prompt({ + sessionID: input.sessionID, + parts: input.parts, + model: input.model, + system: input.system, + agent: input.agent, + }), + ) + } finally { + sessions.delete(input.sessionID) + } + }, + + cancel(sessionID: string) { + return wrap(() => SessionPrompt.cancel(sessionID)) + }, + + subscribe( + event: D, + handler: (payload: { type: D["type"]; properties: any }) => void, + ) { + return wrap(() => Bus.subscribe(event, handler)) + }, + + subscribeAll(handler: (event: any) => void) { + return wrap(() => Bus.subscribeAll(handler)) + }, + + tools: { + register(name: string, definition: ToolDefinition) { + return wrap(async () => { + const hooks = await Plugin.list() + hooks.push({ tool: { [name]: definition } }) + }) + }, + }, + + dispose() { + return wrap(() => Instance.dispose()) + }, + } +} + +export type MiniInstance = ReturnType + +export { Session } from "opencode/session" +export { SessionPrompt } from "opencode/session/prompt" +export { Bus } from "opencode/bus" +export { BusEvent } from "opencode/bus/bus-event" +export { MessageV2 } from "opencode/session/message-v2" +export { tool } from "@opencode-ai/plugin" +export type { Hooks, ToolDefinition, ToolContext } from "@opencode-ai/plugin" diff --git a/packages/opencode-mini/tsconfig.json b/packages/opencode-mini/tsconfig.json new file mode 100644 index 00000000000..ffa5c112157 --- /dev/null +++ b/packages/opencode-mini/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "paths": { + "opencode/*": ["../opencode/src/*"], + "@/*": ["../opencode/src/*"], + "@tui/*": ["../opencode/src/cli/cmd/tui/*"] + } + }, + "include": ["src", "examples"] +} From e48e65bc1f8fa53590f943f3d202f99eee0fec4f Mon Sep 17 00:00:00 2001 From: Krzysztof Cieslak Date: Wed, 11 Mar 2026 13:54:57 +0100 Subject: [PATCH 2/2] Handle Copilot auth --- packages/opencode-mini/.env.sample | 2 + packages/opencode-mini/examples/basic.ts | 109 ++++++++++++---------- packages/opencode-mini/examples/sample.ts | 53 +++++++++++ packages/opencode-mini/src/copilot.ts | 85 +++++++++++++++++ packages/opencode-mini/src/index.ts | 39 +++++++- 5 files changed, 234 insertions(+), 54 deletions(-) create mode 100644 packages/opencode-mini/.env.sample create mode 100644 packages/opencode-mini/examples/sample.ts create mode 100644 packages/opencode-mini/src/copilot.ts diff --git a/packages/opencode-mini/.env.sample b/packages/opencode-mini/.env.sample new file mode 100644 index 00000000000..1c73952bb9c --- /dev/null +++ b/packages/opencode-mini/.env.sample @@ -0,0 +1,2 @@ +GITHUB_TOKEN_ALICE= +GITHUB_TOKEN_BOB= diff --git a/packages/opencode-mini/examples/basic.ts b/packages/opencode-mini/examples/basic.ts index f718d720fb2..2ce8cf177bc 100644 --- a/packages/opencode-mini/examples/basic.ts +++ b/packages/opencode-mini/examples/basic.ts @@ -5,75 +5,85 @@ */ import { create, tool, Session } from "../src/index" -// 1. Create an instance pointed at your project directory -const mini = create({ directory: process.cwd() }) +// --------------------------------------------------------------------------- +// Create an instance with copilot enabled +// --------------------------------------------------------------------------- -// 2. Bootstrap opencode (loads plugins, DB, tools, etc.) +const mini = create({ directory: process.cwd(), copilot: {} }) await mini.init() -// 3. Create a session -const session = await mini.session.create({ title: "My first session" }) -console.log("Created session:", session.id) - -// 4. Send a message and get the assistant response -const response = await mini.prompt({ - sessionID: session.id, - parts: [{ type: "text", text: "What files are in the current directory?" }], -}) -console.log("Assistant responded with", response.parts.length, "parts") - -// 5. Retrieve conversation history -const messages = await mini.session.messages(session.id) -for (const msg of messages) { - console.log(`[${msg.info.role}]`, msg.parts.length, "parts") -} - -// 6. List all sessions -const all = await mini.session.list() -console.log("Total sessions:", all.length) - -// 7. Restore a session by ID -const restored = await mini.session.get(session.id) -console.log("Restored session:", restored.title) - // --------------------------------------------------------------------------- -// Multi-tenant: per-user API credentials +// Multi-tenant copilot: each user brings their own GitHub OAuth token // --------------------------------------------------------------------------- -// Register credentials for different users mini.credentials.set("user-alice", { - providerID: "anthropic", - apiKey: "sk-ant-alice-key", + providerID: "copilot", + token: "gho_alice-github-oauth-token", }) + mini.credentials.set("user-bob", { - providerID: "openai", - apiKey: "sk-bob-key", + providerID: "copilot", + token: "gho_bob-github-oauth-token", }) -// Each prompt specifies which user's credentials to use. -// Credentials are scoped to the prompt lifetime only — different users -// can take turns in the same session, each using their own API key. -const shared = await mini.session.create({ title: "Shared session" }) +const session = await mini.session.create({ title: "Shared session" }) -// Alice sends a message (uses her Anthropic key) +// Alice sends a message routed through copilot with her token await mini.prompt({ - sessionID: shared.id, + sessionID: session.id, parts: [{ type: "text", text: "Hello from Alice" }], userId: "user-alice", - model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + model: { providerID: "copilot", modelID: "claude-sonnet-4-20250514" }, }) -// Bob continues the same conversation (uses his OpenAI key) +// Bob continues the same conversation with his own token await mini.prompt({ - sessionID: shared.id, + sessionID: session.id, parts: [{ type: "text", text: "Hello from Bob" }], userId: "user-bob", - model: { providerID: "openai", modelID: "gpt-4o" }, + model: { providerID: "copilot", modelID: "gpt-4o" }, }) +// Retrieve conversation history +const messages = await mini.session.messages(session.id) +for (const msg of messages) { + console.log(`[${msg.info.role}]`, msg.parts.length, "parts") +} + // Remove credentials when a user logs out mini.credentials.remove("user-alice") +// --------------------------------------------------------------------------- +// API-key providers work too (Anthropic, OpenAI, etc.) +// --------------------------------------------------------------------------- + +mini.credentials.set("user-carol", { + providerID: "anthropic", + token: "sk-ant-carol-key", +}) + +await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello from Carol" }], + userId: "user-carol", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, +}) + +// --------------------------------------------------------------------------- +// Custom copilot model list +// --------------------------------------------------------------------------- + +const custom = create({ + directory: process.cwd(), + copilot: { + provider: "my-copilot", + models: { + "claude-sonnet-4-20250514": { name: "Claude Sonnet 4", limit: { context: 200000, output: 16384 } }, + "gpt-4o": { name: "GPT-4o", limit: { context: 128000, output: 16384 } }, + }, + }, +}) + // --------------------------------------------------------------------------- // Custom tools // --------------------------------------------------------------------------- @@ -90,35 +100,32 @@ await mini.tools.register( ) // --------------------------------------------------------------------------- -// Event subscription +// Events // --------------------------------------------------------------------------- -// Subscribe to all events (raw bus) const unsub = await mini.subscribeAll((event) => { console.log("Event:", event.type) }) -// Subscribe to specific events await mini.subscribe(Session.Event.Created, (event) => { console.log("Session created:", event.properties.info.id) }) -// Unsubscribe when done unsub() // --------------------------------------------------------------------------- -// Cancel an in-progress prompt +// Cancel // --------------------------------------------------------------------------- const long = await mini.session.create({ title: "Cancellable" }) -// Start a prompt in the background const pending = mini.prompt({ sessionID: long.id, parts: [{ type: "text", text: "Write a very long essay about the history of computing" }], + userId: "user-bob", + model: { providerID: "copilot", modelID: "gpt-4o" }, }) -// Cancel it after 2 seconds setTimeout(() => mini.cancel(long.id), 2000) await pending.catch(() => console.log("Prompt cancelled")) diff --git a/packages/opencode-mini/examples/sample.ts b/packages/opencode-mini/examples/sample.ts new file mode 100644 index 00000000000..212f703ef03 --- /dev/null +++ b/packages/opencode-mini/examples/sample.ts @@ -0,0 +1,53 @@ +import { create, tool, Session } from "../src/index" +import { env } from "bun" + +const mini = create({ directory: process.cwd(), copilot: {}, logLevel: "ERROR" }) +await mini.init() + +mini.credentials.set("user-alice", { + providerID: "copilot", + token: env.GITHUB_TOKEN_ALICE ?? "", +}) + +mini.credentials.set("user-bob", { + providerID: "copilot", + token: env.GITHUB_TOKEN_BOB ?? "", +}) + +console.log("[!] Creating session...") +const session = await mini.session.create({ title: "Shared session" }) + +console.log("[!] Alice sends a message...") +let replyA1 = await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello from Alice" }], + userId: "user-alice", + model: { providerID: "copilot", modelID: "claude-opus-4.6" }, +}) +console.log(replyA1) + +console.log("[!] Bob sends a message...") +let replyB1 = await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello from Bob" }], + userId: "user-bob", + model: { providerID: "copilot", modelID: "claude-opus-4.6" }, +}) +console.log(replyB1) + +console.log("[!] Alice asks who's in the conversation...") +let replyA2 = await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Who is part of this conversation?" }], + userId: "user-alice", + model: { providerID: "copilot", modelID: "claude-opus-4.6" }, +}) +console.log(replyA2) + +// console.log("[!] Retrieving conversation history...") +// const messages = await mini.session.messages(session.id) +// for (const msg of messages) { +// console.log(`[${msg.info.role}]`, msg) +// } + +await mini.dispose() diff --git a/packages/opencode-mini/src/copilot.ts b/packages/opencode-mini/src/copilot.ts new file mode 100644 index 00000000000..5b270f5b83e --- /dev/null +++ b/packages/opencode-mini/src/copilot.ts @@ -0,0 +1,85 @@ +import { Installation } from "opencode/installation" +import { ModelsDev } from "opencode/provider/models" + +const TOKEN_HEADER = "x-copilot-token" +const BASE_URL = "https://api.githubcopilot.com" + +function detect(url: string, init?: RequestInit) { + try { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body + + // Completions API + if (body?.messages && url.includes("completions")) { + const last = body.messages[body.messages.length - 1] + return { + vision: body.messages.some( + (msg: any) => Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), + ), + agent: last?.role !== "user", + } + } + + // Responses API + if (body?.input) { + const last = body.input[body.input.length - 1] + return { + vision: body.input.some( + (item: any) => Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), + ), + agent: last?.role !== "user", + } + } + + // Messages API + if (body?.messages) { + const last = body.messages[body.messages.length - 1] + const hasNonTool = Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result") + return { + vision: body.messages.some( + (item: any) => + Array.isArray(item?.content) && + item.content.some( + (part: any) => + part?.type === "image" || + (part?.type === "tool_result" && + Array.isArray(part?.content) && + part.content.some((nested: any) => nested?.type === "image")), + ), + ), + agent: !(last?.role === "user" && hasNonTool), + } + } + } catch {} + return { vision: false, agent: false } +} + +export async function copilotFetch(request: RequestInfo | URL, init?: RequestInit) { + const incoming = (init?.headers ?? {}) as Record + const token = incoming[TOKEN_HEADER] + if (!token) return fetch(request, init) + + const url = request instanceof URL ? request.href : request.toString() + const { vision, agent } = detect(url, init) + + const headers: Record = { + "x-initiator": agent ? "agent" : "user", + ...incoming, + "User-Agent": `opencode/${Installation.VERSION}`, + Authorization: `Bearer ${token}`, + "Openai-Intent": "conversation-edits", + } + if (vision) headers["Copilot-Vision-Request"] = "true" + + delete headers[TOKEN_HEADER] + delete headers["x-api-key"] + delete headers["authorization"] + + return fetch(request, { ...init, headers }) +} + +export { TOKEN_HEADER, BASE_URL } + +export async function models() { + const data = await ModelsDev.get() + return data["github-copilot"]?.models ?? {} +} diff --git a/packages/opencode-mini/src/index.ts b/packages/opencode-mini/src/index.ts index 22697a05a15..d6fdf46747e 100644 --- a/packages/opencode-mini/src/index.ts +++ b/packages/opencode-mini/src/index.ts @@ -5,18 +5,28 @@ import { Bus } from "opencode/bus" import { Instance } from "opencode/project/instance" import { InstanceBootstrap } from "opencode/project/bootstrap" import { Plugin } from "opencode/plugin" +import { Config } from "opencode/config/config" import type { MessageV2 } from "opencode/session/message-v2" import type { BusEvent } from "opencode/bus/bus-event" +import { Log } from "opencode/util/log" +import { copilotFetch, TOKEN_HEADER, BASE_URL, models as copilotModels } from "./copilot" type Credentials = { providerID: string - apiKey: string + token: string } -export function create(opts: { directory: string }) { +type CopilotConfig = { + provider?: string + models?: Record> +} + +export function create(opts: { directory: string; copilot?: CopilotConfig; logLevel?: Log.Level }) { const dir = opts.directory const creds = new Map() const sessions = new Map() + const copilot = opts.copilot + const copilotID = copilot?.provider ?? "copilot" let ready = false function wrap(fn: () => T) { @@ -29,7 +39,26 @@ export function create(opts: { directory: string }) { async function init() { if (ready) return + await Log.init({ print: true, level: opts.logLevel ?? "ERROR" }) await wrap(async () => { + // Register copilot provider via config mutation before Provider.state() runs + if (copilot !== undefined) { + const config = await Config.get() + const fetched = await copilotModels() + const merged = { ...fetched, ...(copilot.models ?? {}) } + config.provider = config.provider ?? {} + config.provider[copilotID] = { + name: "Copilot", + npm: "@ai-sdk/github-copilot", + models: merged, + options: { + apiKey: "", + baseURL: BASE_URL, + fetch: copilotFetch, + }, + } + } + const hooks = await Plugin.list() hooks.push({ "chat.headers": async (input, output) => { @@ -38,7 +67,11 @@ export function create(opts: { directory: string }) { const cred = creds.get(uid) if (!cred) return if (cred.providerID !== input.model.providerID) return - output.headers["Authorization"] = "Bearer " + cred.apiKey + if (cred.providerID === copilotID) { + output.headers[TOKEN_HEADER] = cred.token + } else { + output.headers["Authorization"] = "Bearer " + cred.token + } }, }) })