diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index dee12a3e0e..7ea866af1c 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -41,6 +41,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () { const route = (provider?: TextGenerationProvider): TextGenerationShape => provider === "claudeAgent" ? claude : codex; + // Note: opencode falls through to codex for text generation. return { generateCommitMessage: (input) => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 0000000000..3637ec0a07 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,463 @@ +/** + * OpenCodeAdapterLive - OpenCode provider adapter using `opencode run`. + * + * Uses `opencode run` CLI command in non-interactive mode instead of + * spawning a persistent server. This is simpler and more reliable — + * each turn is a standalone CLI invocation with JSON output. + * + * @module OpenCodeAdapterLive + */ +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { spawn } from "node:child_process"; +import { DateTime, Effect, Layer, Queue, Random, Result, Stream } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; + +const PROVIDER = "opencode" as const; + +interface SessionContext { + session: ProviderSession; + abortController: AbortController | undefined; + stopped: boolean; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) return cause.message; + return fallback; +} + +/** + * Parse an OpenCode model slug ("provider/model") into the format + * that `opencode run --model` expects: "provider/model". + */ +function formatModelFlag(slug: string): string | undefined { + if (!slug || slug === "default") return undefined; + return slug; // opencode run --model accepts "provider/model" directly +} + +/** + * Run `opencode run` with the given prompt and return the output. + */ +async function runOpenCodeCli(input: { + binaryPath: string; + prompt: string; + model: string; + cwd: string; + signal: AbortSignal; +}): Promise<{ output: string; exitCode: number }> { + const args = ["run"]; + const modelFlag = formatModelFlag(input.model); + if (modelFlag) { + args.push("--model", modelFlag); + } + args.push(input.prompt); + + return new Promise((resolve, reject) => { + const child = spawn(input.binaryPath, args, { + cwd: input.cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + env: { ...process.env }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + const onAbort = () => { + child.kill(); + reject(new Error("Aborted")); + }; + input.signal.addEventListener("abort", onAbort, { once: true }); + if (input.signal.aborted) { + child.kill(); + reject(new Error("Aborted")); + return; + } + + child.on("error", (err) => { + input.signal.removeEventListener("abort", onAbort); + reject(err); + }); + + child.on("exit", (code) => { + input.signal.removeEventListener("abort", onAbort); + resolve({ output: stdout || stderr, exitCode: code ?? 1 }); + }); + }); +} + +// ── Adapter implementation ──────────────────────────────────────────── + +const makeOpenCodeAdapter = Effect.fn("makeOpenCodeAdapter")(function* () { + const serverSettingsService = yield* ServerSettingsService; + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const getSettings = serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.opencode), + ); + + const getSession = (threadId: ThreadId): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx) + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + if (ctx.stopped) + return Effect.fail(new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId })); + return Effect.succeed(ctx); + }; + + // ── Run turn via `opencode run` ───────────────────────────────────── + + const runTurn = Effect.fn("runTurn")(function* ( + ctx: SessionContext, + userText: string, + model: string, + turnId: TurnId, + ) { + const settings = yield* getSettings; + const cwd = ctx.session.cwd ?? process.cwd(); + const abortController = new AbortController(); + ctx.abortController = abortController; + + // Emit turn started + const turnStartStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartStamp.eventId, + provider: PROVIDER, + createdAt: turnStartStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + payload: { model }, + providerRefs: {}, + }); + + // Session state → running + const runningStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: runningStamp.eventId, + provider: PROVIDER, + createdAt: runningStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + payload: { state: "running" }, + providerRefs: {}, + }); + ctx.session = { ...ctx.session, status: "running" }; + + let turnStatus: "completed" | "failed" | "interrupted" = "completed"; + let errorMessage: string | undefined; + let responseText = ""; + + // Run opencode CLI + const cliResult = yield* Effect.tryPromise({ + try: () => + runOpenCodeCli({ + binaryPath: settings.binaryPath, + prompt: userText, + model, + cwd, + signal: abortController.signal, + }), + catch: (err) => err as Error, + }).pipe(Effect.result); + + if (Result.isFailure(cliResult)) { + if (abortController.signal.aborted) { + turnStatus = "interrupted"; + errorMessage = "Request interrupted."; + } else { + turnStatus = "failed"; + errorMessage = toMessage(cliResult.failure, "OpenCode CLI failed"); + } + } else { + const { output, exitCode } = cliResult.success; + if (exitCode !== 0) { + turnStatus = "failed"; + errorMessage = output.trim().length > 0 + ? output.trim() + : `OpenCode exited with code ${exitCode}`; + } else { + responseText = output; + } + } + + // Emit assistant message + if (responseText.length > 0) { + const itemId = yield* Random.nextUUIDv4; + + const startStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: startStamp.eventId, + provider: PROVIDER, + createdAt: startStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + itemId: RuntimeItemId.makeUnsafe(itemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + }, + providerRefs: {}, + }); + + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + itemId: RuntimeItemId.makeUnsafe(itemId), + payload: { streamKind: "assistant_text", delta: responseText }, + providerRefs: {}, + }); + + const completeItemStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: completeItemStamp.eventId, + provider: PROVIDER, + createdAt: completeItemStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + itemId: RuntimeItemId.makeUnsafe(itemId), + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + detail: responseText, + }, + providerRefs: {}, + }); + } + + // Emit error if needed + if (turnStatus === "failed" && errorMessage) { + const errorStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: errorStamp.eventId, + provider: PROVIDER, + createdAt: errorStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + payload: { message: errorMessage, class: "provider_error" }, + providerRefs: {}, + }); + } + + // Turn completed + const completeStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: completeStamp.eventId, + provider: PROVIDER, + createdAt: completeStamp.createdAt, + threadId: ctx.session.threadId, + turnId, + payload: { state: turnStatus, ...(errorMessage ? { errorMessage } : {}) }, + providerRefs: {}, + }); + + // Session → ready + const updatedAt = yield* nowIso; + ctx.session = { + ...ctx.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(turnStatus === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + ctx.abortController = undefined; + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId: ctx.session.threadId, + payload: { state: "ready" }, + providerRefs: {}, + }); + }); + + // ── ProviderAdapterShape ──────────────────────────────────────────── + + const startSession: OpenCodeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const threadId = input.threadId; + const model = input.modelSelection?.model ?? "default"; + const createdAt = yield* nowIso; + + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: input.cwd, + model, + threadId, + createdAt, + updatedAt: createdAt, + }; + + const ctx: SessionContext = { + session, + abortController: undefined, + stopped: false, + }; + sessions.set(threadId, ctx); + + // Emit session started + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId, + payload: {}, + providerRefs: {}, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId, + payload: { state: "ready" }, + providerRefs: {}, + }); + + return session; + }); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* getSession(input.threadId); + if (ctx.session.activeTurnId) { + return yield* Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }), + ); + } + const model = input.modelSelection?.model ?? ctx.session.model ?? "default"; + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const userText = input.input?.trim() ?? ""; + + ctx.session = { ...ctx.session, model, activeTurnId: turnId }; + + // Run in background + const services = yield* Effect.services(); + Effect.runForkWith(services)(runTurn(ctx, userText, model, turnId)); + + return { threadId: input.threadId, turnId }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* getSession(threadId); + if (ctx.abortController) ctx.abortController.abort(); + }); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = () => Effect.void; + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = () => Effect.void; + + const stopSession: OpenCodeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) return; + ctx.stopped = true; + if (ctx.abortController) ctx.abortController.abort(); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId, + payload: { reason: "Session stopped." }, + providerRefs: {}, + }); + sessions.delete(threadId); + }); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()) + .filter((c) => !c.stopped) + .map((c) => c.session), + ); + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId) && !sessions.get(threadId)!.stopped); + const readThread: OpenCodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + yield* getSession(threadId); + return { threadId, turns: [] }; + }); + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = (threadId) => + Effect.gen(function* () { + yield* getSession(threadId); + return { threadId, turns: [] }; + }); + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.gen(function* () { + for (const tid of Array.from(sessions.keys())) yield* stopSession(tid); + }); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" as const }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies OpenCodeAdapterShape; +}); + +export const OpenCodeAdapterLive = Layer.effect(OpenCodeAdapter, makeOpenCodeAdapter()); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 0000000000..2a25f24619 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,428 @@ +/** + * OpenCodeProviderLive - Provider snapshot layer for OpenCode. + * + * Probes the `opencode` CLI for installation, version, authentication, and + * discovers available models by running `opencode models --verbose`. + * Models are parsed with their capabilities (reasoning, context window, + * tool calling) and mapped to T3's ModelCapabilities. + * + * @module OpenCodeProviderLive + */ +import type { + ModelCapabilities, + OpenCodeSettings, + ServerProvider, + ServerProviderModel, + ServerProviderAuth, + ServerProviderState, +} from "@t3tools/contracts"; +import { ServerSettingsError } from "@t3tools/contracts"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type CommandResult, +} from "../providerSnapshot.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const PROVIDER = "opencode" as const; +const MODELS_TIMEOUT_MS = 10_000; + +// ── OpenCode model JSON types ───────────────────────────────────────── + +interface OpenCodeModelJson { + id: string; + providerID: string; + name: string; + family?: string; + status?: string; + limit?: { + context?: number; + output?: number; + input?: number; + }; + capabilities?: { + reasoning?: boolean; + toolcall?: boolean; + attachment?: boolean; + temperature?: boolean; + }; + cost?: { + input?: number; + output?: number; + }; +} + +// ── Capability mapping ──────────────────────────────────────────────── + +function contextWindowLabel(tokens: number): string { + if (tokens >= 1_000_000) return `${Math.round(tokens / 100_000) / 10}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return String(tokens); +} + +function buildModelCapabilities(model: OpenCodeModelJson): ModelCapabilities { + const hasReasoning = model.capabilities?.reasoning === true; + const contextTokens = model.limit?.context ?? 0; + + // Reasoning effort levels (only for models that support reasoning) + const reasoningEffortLevels = hasReasoning + ? [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true as const }, + ] + : []; + + // Context window options (only if context > 0) + const contextWindowOptions = + contextTokens > 0 + ? [ + { + value: contextWindowLabel(contextTokens), + label: contextWindowLabel(contextTokens), + isDefault: true as const, + }, + ] + : []; + + return { + reasoningEffortLevels, + supportsFastMode: false, + supportsThinkingToggle: hasReasoning, + contextWindowOptions, + promptInjectedEffortLevels: [], + }; +} + +function buildDisplayName(model: OpenCodeModelJson): string { + // Use the model's name if available, otherwise format the ID + if (model.name && model.name.length > 0 && model.name !== model.id) { + return model.name; + } + // Format: provider/model-id → "Provider Model-Id" + return model.id + .split(/[-_]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** + * Parse the verbose output of `opencode models --verbose`. + * Format: alternating lines of "provider/model-id" followed by JSON blocks. + */ +function parseOpenCodeModelsOutput(stdout: string): ServerProviderModel[] { + const models: ServerProviderModel[] = []; + const lines = stdout.split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]!.trim(); + + // Look for provider/model-id lines + if (line.length > 0 && !line.startsWith("{") && line.includes("/")) { + const slug = line; // e.g. "opencode/claude-opus-4-6" + + // Collect the JSON block that follows + let jsonStr = ""; + let braceDepth = 0; + let foundJson = false; + + for (let j = i + 1; j < lines.length; j++) { + const jsonLine = lines[j]!; + if (!foundJson && jsonLine.trim().startsWith("{")) { + foundJson = true; + } + if (foundJson) { + jsonStr += jsonLine + "\n"; + for (const ch of jsonLine) { + if (ch === "{") braceDepth++; + if (ch === "}") braceDepth--; + } + if (braceDepth <= 0 && jsonStr.trim().length > 0) { + i = j + 1; + break; + } + } + // Exhausted lines without balanced braces — advance past them + if (j === lines.length - 1) { + i = j + 1; + } + } + + if (jsonStr.trim().length > 0) { + try { + const parsed = JSON.parse(jsonStr) as OpenCodeModelJson; + parsed.providerID = parsed.providerID ?? slug.split("/")[0] ?? ""; + + const capabilities = buildModelCapabilities(parsed); + const contextTokens = parsed.limit?.context ?? 0; + const costInfo = + parsed.cost && (parsed.cost.input || parsed.cost.output) + ? ` · $${parsed.cost.input ?? 0}/$${parsed.cost.output ?? 0} per 1M tokens` + : ""; + const contextInfo = + contextTokens > 0 ? ` · ${contextWindowLabel(contextTokens)} ctx` : ""; + + models.push({ + slug, + name: `${buildDisplayName(parsed)}${contextInfo}${costInfo}`, + isCustom: false, + capabilities, + }); + } catch { + // Skip unparseable entries + models.push({ + slug, + name: slug, + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }); + } + continue; + } + } + + i++; + } + + return models; +} + +// ── Auth status parsing ─────────────────────────────────────────────── + +export function parseOpenCodeAuthStatus(result: CommandResult): { + readonly status: Exclude; + readonly auth: Pick; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("no providers") || + lowerOutput.includes("no credentials") || + lowerOutput.includes("not authenticated") || + lowerOutput.includes("no api key") + ) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "OpenCode has no configured providers. Run `opencode auth login` to add one.", + }; + } + + if (result.code === 0) { + return { status: "ready", auth: { status: "authenticated" } }; + } + + const detail = `${result.stdout}\n${result.stderr}`.trim(); + return { + status: "warning", + auth: { status: "unknown" }, + message: detail + ? `Could not verify OpenCode authentication status. ${detail}` + : "Could not verify OpenCode authentication status.", + }; +} + +// ── CLI runner ──────────────────────────────────────────────────────── + +const runOpenCodeCommand = Effect.fn("runOpenCodeCommand")(function* (args: ReadonlyArray) { + const settingsService = yield* ServerSettingsService; + const opencodeSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + const command = ChildProcess.make(opencodeSettings.binaryPath, [...args], { + shell: process.platform === "win32", + }); + return yield* spawnAndCollect(opencodeSettings.binaryPath, command); +}); + +// ── Provider status check ───────────────────────────────────────────── + +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + > { + const settingsService = yield* ServerSettingsService; + const opencodeSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + const checkedAt = new Date().toISOString(); + + if (!opencodeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + // ── Version check ───────────────────────────────────────────────── + const versionProbe = yield* runOpenCodeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: opencodeSettings.enabled, + checkedAt, + models: [], + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "OpenCode CLI (`opencode`) is not installed or not on PATH. Install from https://opencode.ai" + : `Failed to execute OpenCode CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: opencodeSettings.enabled, + checkedAt, + models: [], + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "OpenCode CLI is installed but timed out.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = `${version.stdout}\n${version.stderr}`.trim(); + return buildServerProvider({ + provider: PROVIDER, + enabled: opencodeSettings.enabled, + checkedAt, + models: [], + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: detail + ? `OpenCode CLI is installed but failed to run. ${detail}` + : "OpenCode CLI is installed but failed to run.", + }, + }); + } + + // ── Auth check ──────────────────────────────────────────────────── + const authProbe = yield* runOpenCodeCommand(["auth", "list"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + let authStatus: ReturnType = { + status: "warning", + auth: { status: "unknown" }, + }; + if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { + authStatus = parseOpenCodeAuthStatus(authProbe.success.value); + } + + // ── Model discovery ─────────────────────────────────────────────── + const modelsProbe = yield* runOpenCodeCommand(["models", "--verbose"]).pipe( + Effect.timeoutOption(MODELS_TIMEOUT_MS), + Effect.result, + ); + + let discoveredModels: ServerProviderModel[] = []; + let modelCount = 0; + + if (Result.isSuccess(modelsProbe) && Option.isSome(modelsProbe.success)) { + const modelsResult = modelsProbe.success.value; + if (modelsResult.code === 0) { + discoveredModels = parseOpenCodeModelsOutput(modelsResult.stdout); + modelCount = discoveredModels.length; + } + } + + // Merge with any custom models from settings + const allModels = providerModelsFromSettings( + discoveredModels, + PROVIDER, + opencodeSettings.customModels, + ); + + return buildServerProvider({ + provider: PROVIDER, + enabled: opencodeSettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: authStatus.status, + auth: authStatus.auth, + message: + authStatus.message ?? + `${modelCount} model(s) available across ${new Set(discoveredModels.map((m) => m.slug.split("/")[0])).size} provider(s).`, + }, + }); + }, +); + +// ── Layer ───────────────────────────────────────────────────────────── + +export const OpenCodeProviderLive = Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkOpenCodeProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c..2b06c07b62 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +29,11 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [ + yield* CodexAdapter, + yield* ClaudeAdapter, + yield* OpenCodeAdapter, + ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c293..2b78a530aa 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -8,19 +8,28 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { OpenCodeProviderLive } from "./OpenCodeProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; +import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { - concurrency: "unbounded", - }); + opencodeProvider: OpenCodeProviderShape, +): Effect.Effect => + Effect.all( + [ + codexProvider.getSnapshot, + claudeProvider.getSnapshot, + opencodeProvider.getSnapshot, + ], + { concurrency: "unbounded" }, + ); export const haveProvidersChanged = ( previousProviders: ReadonlyArray, @@ -32,19 +41,28 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const opencodeProvider = yield* OpenCodeProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders( + codexProvider, + claudeProvider, + opencodeProvider, + ), ); const syncProviders = Effect.fn("syncProviders")(function* (options?: { readonly publish?: boolean; }) { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders( + codexProvider, + claudeProvider, + opencodeProvider, + ); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -60,6 +78,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(opencodeProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { @@ -69,10 +90,18 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "opencode": + yield* opencodeProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [ + codexProvider.refresh, + claudeProvider.refresh, + opencodeProvider.refresh, + ], + { concurrency: "unbounded" }, + ); break; } return yield* syncProviders(); @@ -93,4 +122,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(OpenCodeProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d696..a2cb8caf71 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,8 +22,12 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { - return Effect.succeed(providerName); + if ( + providerName === "codex" || + providerName === "claudeAgent" || + providerName === "opencode" + ) { + return Effect.succeed(providerName as ProviderKind); } return Effect.fail( new ProviderSessionDirectoryPersistenceError({ diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 0000000000..e5e280b374 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,26 @@ +/** + * OpenCodeAdapter - OpenCode coding agent provider adapter contract. + * + * This service wraps the OpenCode CLI/server as a coding agent provider, + * communicating via the OpenCode HTTP API with SSE event streaming. + * + * @module OpenCodeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenCodeAdapterShape - Service API for the OpenCode provider adapter. + */ +export interface OpenCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "opencode"; +} + +/** + * OpenCodeAdapter - Service tag for OpenCode provider adapter operations. + */ +export class OpenCodeAdapter extends ServiceMap.Service()( + "t3/provider/Services/OpenCodeAdapter", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts new file mode 100644 index 0000000000..094c877593 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface OpenCodeProviderShape extends ServerProviderShape {} + +export class OpenCodeProvider extends ServiceMap.Service()( + "t3/provider/Services/OpenCodeProvider", +) {} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 04ffaeeeeb..f682b1a724 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -14,6 +14,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { OpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; @@ -146,6 +147,7 @@ const ProviderLayerLive = Layer.unwrap( const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(OpenCodeAdapterLive), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 79fdc29a8b..7e6619bf9d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -91,7 +91,13 @@ export class ServerSettingsService extends ServiceMap.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +// Providers eligible for text generation fallback. OpenCode is excluded +// because it lacks a dedicated text generation implementation and would +// route to codex with an invalid "default" model slug. +const TEXT_GENERATION_PROVIDER_ORDER: readonly ProviderKind[] = [ + "codex", + "claudeAgent", +]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. @@ -105,7 +111,7 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings return settings; } - const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + const fallback = TEXT_GENERATION_PROVIDER_ORDER.find((p) => settings.providers[p].enabled); if (!fallback) { // No providers enabled — return as-is; callers will report the error. return settings; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9d51fa5061..1bd9a28be1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1406,6 +1406,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + opencode: providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], }), [providerStatuses], ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2e95b54e25..0d486909ba 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -412,14 +412,27 @@ export const IntelliJIdeaIcon: Icon = (props) => { }; export const OpenCodeIcon: Icon = (props) => ( - - - - + + + + + + + + + - - + + diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 187ecf497a..605412376d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -71,6 +71,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + opencode: { enabled: true, binaryPath: "opencode", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 01fa37516e..01fa2f146a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -33,21 +33,23 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, + opencode: OpenCodeIcon, cursor: CursorIcon, }; export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ - { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, + { id: "gemini" as const, label: "Gemini", icon: Gemini }, ] as const; function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; + if (provider === "claudeAgent") return "text-[#d97757]"; + if (provider === "opencode") return "text-[#8b5cf6]"; + return fallbackClassName; } export const ProviderModelPicker = memo(function ProviderModelPicker(props: { diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..4c1da99045 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -91,6 +91,38 @@ function getProviderStateFromCapabilities( } const composerProviderRegistry: Record = { + opencode: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( + + ), + }, codex: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..aadad68652 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -112,6 +112,21 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, + { + provider: "opencode", + title: "OpenCode", + binaryPlaceholder: "OpenCode binary path", + binaryDescription: ( + <> + Path to the OpenCode binary. A full coding agent with built-in tools. +
+ Supports any LLM provider. Install from{" "} + + opencode.ai + + + ), + }, ] as const; const PROVIDER_STATUS_STYLES = { @@ -537,12 +552,18 @@ export function GeneralSettingsPanel() { DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0, ), + opencode: Boolean( + settings.providers.opencode.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath || + settings.providers.opencode.customModels.length > 0, + ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", + opencode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..1b723c3610 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -407,7 +407,11 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" ? value : null; + return value === "codex" || + value === "claudeAgent" || + value === "opencode" + ? value + : null; } function normalizeProviderModelOptions( @@ -492,12 +496,22 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const opencodeCandidate = + candidate?.opencode && typeof candidate.opencode === "object" + ? (candidate.opencode as Record) + : null; + const opencode = + opencodeCandidate && Object.keys(opencodeCandidate).length > 0 + ? { ...opencodeCandidate } + : undefined; + + if (!codex && !claude && !opencode) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(opencode ? { opencode } : {}), }; } @@ -528,7 +542,14 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; + const options = + provider === "codex" + ? modelOptions?.codex + : provider === "claudeAgent" + ? modelOptions?.claudeAgent + : provider === "opencode" + ? modelOptions?.opencode + : undefined; return { provider, model, @@ -594,7 +615,7 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "opencode"] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { result[provider] = { @@ -1676,7 +1697,11 @@ export const useComposerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "opencode", + ] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adf..56aac5f65d 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -45,6 +45,14 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "opencode", label: "OpenCode", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 12c709b796..f697dbf3c8 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -80,9 +80,9 @@ function updateProject( return changed ? next : projects; } -function normalizeModelSelection( - selection: T, -): T { +function normalizeModelSelection< + T extends { provider: "codex" | "claudeAgent" | "opencode"; model: string }, +>(selection: T): T { return { ...selection, model: resolveModelSlugForProvider(selection.provider, selection.model), @@ -493,7 +493,11 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if ( + providerName === "codex" || + providerName === "claudeAgent" || + providerName === "opencode" + ) { return providerName; } return "codex"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e05..86a4dfe3bd 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -22,9 +22,13 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const OpenCodeModelOptions = Schema.Struct({}); +export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + opencode: Schema.optional(OpenCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -54,6 +58,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + opencode: "default", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -62,6 +67,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", + opencode: "default", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -86,6 +92,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + opencode: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..ee73db6d30 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,7 +23,11 @@ export const ORCHESTRATION_WS_METHODS = { replayEvents: "orchestration.replayEvents", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals([ + "codex", + "claudeAgent", + "opencode", +]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -55,7 +59,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const OpenCodeModelSelection = Schema.Struct({ + provider: Schema.Literal("opencode"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(Schema.Struct({})), +}); +export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + OpenCodeModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f6..60f3beb39b 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "opencode.api", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6..1eb2e7520e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -71,6 +71,13 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const OpenCodeSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("opencode"), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type OpenCodeSettings = typeof OpenCodeSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), @@ -93,6 +100,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); @@ -146,6 +154,10 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("opencode")), + model: Schema.optionalKey(TrimmedNonEmptyString), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -161,6 +173,12 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OpenCodeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -175,6 +193,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2aa378cf63..1040820a81 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -217,6 +217,7 @@ export function resolveApiModelId(modelSelection: ModelSelection): string { return modelSelection.model; } } + case "opencode": default: { return modelSelection.model; }