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/.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 new file mode 100644 index 00000000000..2ce8cf177bc --- /dev/null +++ b/packages/opencode-mini/examples/basic.ts @@ -0,0 +1,136 @@ +/** + * opencode-mini usage examples + * + * Run with: bun run packages/opencode-mini/examples/basic.ts + */ +import { create, tool, Session } from "../src/index" + +// --------------------------------------------------------------------------- +// Create an instance with copilot enabled +// --------------------------------------------------------------------------- + +const mini = create({ directory: process.cwd(), copilot: {} }) +await mini.init() + +// --------------------------------------------------------------------------- +// Multi-tenant copilot: each user brings their own GitHub OAuth token +// --------------------------------------------------------------------------- + +mini.credentials.set("user-alice", { + providerID: "copilot", + token: "gho_alice-github-oauth-token", +}) + +mini.credentials.set("user-bob", { + providerID: "copilot", + token: "gho_bob-github-oauth-token", +}) + +const session = await mini.session.create({ title: "Shared session" }) + +// Alice sends a message routed through copilot with her token +await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello from Alice" }], + userId: "user-alice", + model: { providerID: "copilot", modelID: "claude-sonnet-4-20250514" }, +}) + +// Bob continues the same conversation with his own token +await mini.prompt({ + sessionID: session.id, + parts: [{ type: "text", text: "Hello from Bob" }], + userId: "user-bob", + 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 +// --------------------------------------------------------------------------- + +await mini.tools.register( + "current_time", + tool({ + description: "Returns the current date and time", + args: {}, + async execute() { + return new Date().toISOString() + }, + }), +) + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +const unsub = await mini.subscribeAll((event) => { + console.log("Event:", event.type) +}) + +await mini.subscribe(Session.Event.Created, (event) => { + console.log("Session created:", event.properties.info.id) +}) + +unsub() + +// --------------------------------------------------------------------------- +// Cancel +// --------------------------------------------------------------------------- + +const long = await mini.session.create({ title: "Cancellable" }) + +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" }, +}) + +setTimeout(() => mini.cancel(long.id), 2000) +await pending.catch(() => console.log("Prompt cancelled")) + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +await mini.dispose() 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/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/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 new file mode 100644 index 00000000000..d6fdf46747e --- /dev/null +++ b/packages/opencode-mini/src/index.ts @@ -0,0 +1,170 @@ +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 { 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 + token: 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) { + return Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn, + }) + } + + 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) => { + const uid = sessions.get(input.sessionID) + if (!uid) return + const cred = creds.get(uid) + if (!cred) return + if (cred.providerID !== input.model.providerID) return + if (cred.providerID === copilotID) { + output.headers[TOKEN_HEADER] = cred.token + } else { + output.headers["Authorization"] = "Bearer " + cred.token + } + }, + }) + }) + 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"] +}