diff --git a/bun.lock b/bun.lock
index 483f551d31..c700ba66ec 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -223,7 +223,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -266,7 +266,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -295,7 +295,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -311,7 +311,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.4.1",
+ "version": "1.4.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -447,7 +447,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -481,7 +481,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -496,7 +496,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -531,7 +531,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -580,7 +580,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"zod": "catalog:",
},
@@ -591,7 +591,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
diff --git a/packages/app/package.json b/packages/app/package.json
index a052793d82..3e12c492b6 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.4.1",
+ "version": "1.4.2",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 786c2baeda..dc3362e979 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 5184c2fc0a..9059734ccd 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.4.1",
+ "version": "1.4.2",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index b34fa73777..d8f0e0c0e2 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.4.1",
+ "version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 04a0a06bae..0976bb3e21 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index f8274a4759..48c17d0df2 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 016a205bdb..943cbeb203 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index c9d98dc039..10a909c8c6 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.4.1",
+ "version": "1.4.2",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index 7c49722a58..95fc2b1c53 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.4.1"
+version = "1.4.2"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index baeee69438..dd54e9c092 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.4.1",
+ "version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index f842a97bf5..934ef0869c 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.4.1",
+ "version": "1.4.2",
"name": "opencode",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts
index c45b9e55d0..41e498102e 100644
--- a/packages/opencode/src/cli/cmd/mcp.ts
+++ b/packages/opencode/src/cli/cmd/mcp.ts
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 18335b6e5f..94308bb034 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -189,7 +189,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
- const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
+ const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 1952e3b572..ff79b739fe 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -399,6 +399,10 @@ export namespace Config {
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
+ redirectUri: z
+ .string()
+ .optional()
+ .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts
index cb12b4c52b..38c45a6342 100644
--- a/packages/opencode/src/effect/runner.ts
+++ b/packages/opencode/src/effect/runner.ts
@@ -1,10 +1,10 @@
-import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
+import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
export interface Runner {
readonly state: Runner.State
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect) => Effect.Effect
- readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect
+ readonly startShell: (work: Effect.Effect) => Effect.Effect
readonly cancel: Effect.Effect
}
@@ -20,7 +20,6 @@ export namespace Runner {
interface ShellHandle {
id: number
fiber: Fiber.Fiber
- abort: AbortController
}
interface PendingHandle {
@@ -100,13 +99,7 @@ export namespace Runner {
}),
).pipe(Effect.flatten)
- const stopShell = (shell: ShellHandle) =>
- Effect.gen(function* () {
- shell.abort.abort()
- const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
- if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
- yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
- })
+ const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber)
const ensureRunning = (work: Effect.Effect) =>
SynchronizedRef.modifyEffect(
@@ -138,7 +131,7 @@ export namespace Runner {
),
)
- const startShell = (work: (signal: AbortSignal) => Effect.Effect) =>
+ const startShell = (work: Effect.Effect) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
@@ -153,9 +146,8 @@ export namespace Runner {
}
yield* busy
const id = next()
- const abort = new AbortController()
- const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
- const shell = { id, fiber, abort } satisfies ShellHandle
+ const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
+ const shell = { id, fiber } satisfies ShellHandle
return [
Effect.gen(function* () {
const exit = yield* Fiber.await(fiber)
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 45e3e2567a..3196c87768 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -286,6 +286,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@@ -716,13 +717,16 @@ export namespace MCP {
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
- yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
+ // OAuth config is optional - if not provided, we'll use auto-discovery
+ const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
+
+ // Start the callback server with custom redirectUri if configured
+ yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
yield* auth.updateOAuthState(mcpName, oauthState)
- const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
let capturedUrl: URL | undefined
const authProvider = new McpOAuthProvider(
mcpName,
@@ -731,6 +735,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts
index dd1d886fc1..b5b6a7a6eb 100644
--- a/packages/opencode/src/mcp/oauth-callback.ts
+++ b/packages/opencode/src/mcp/oauth-callback.ts
@@ -1,10 +1,14 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
-import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
+import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
+// Current callback server configuration (may differ from defaults if custom redirectUri is used)
+let currentPort = OAUTH_CALLBACK_PORT
+let currentPath = OAUTH_CALLBACK_PATH
+
const HTML_SUCCESS = `
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
- const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
+ const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
- if (url.pathname !== OAUTH_CALLBACK_PATH) {
+ if (url.pathname !== currentPath) {
res.writeHead(404)
res.end("Not found")
return
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
res.end(HTML_SUCCESS)
}
- export async function ensureRunning(): Promise {
+ export async function ensureRunning(redirectUri?: string): Promise {
+ // Parse the redirect URI to get port and path (uses defaults if not provided)
+ const { port, path } = parseRedirectUri(redirectUri)
+
+ // If server is running on a different port/path, stop it first
+ if (server && (currentPort !== port || currentPath !== path)) {
+ log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
+ await stop()
+ }
+
if (server) return
- const running = await isPortInUse()
+ const running = await isPortInUse(port)
if (running) {
- log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
+ log.info("oauth callback server already running on another instance", { port })
return
}
+ currentPort = port
+ currentPath = path
+
server = createServer(handleRequest)
await new Promise((resolve, reject) => {
- server!.listen(OAUTH_CALLBACK_PORT, () => {
- log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
+ server!.listen(currentPort, () => {
+ log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve()
})
server!.on("error", reject)
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
}
}
- export async function isPortInUse(): Promise {
+ export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise {
return new Promise((resolve) => {
- const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
+ const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts
index b4da73169e..d675fc71e4 100644
--- a/packages/opencode/src/mcp/oauth-provider.ts
+++ b/packages/opencode/src/mcp/oauth-provider.ts
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
+ redirectUri?: string
}
export interface McpOAuthCallbacks {
@@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
) {}
get redirectUrl(): string {
+ if (this.config.redirectUri) {
+ return this.config.redirectUri
+ }
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
}
@@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
+
+/**
+ * Parse a redirect URI to extract port and path for the callback server.
+ * Returns defaults if the URI can't be parsed.
+ */
+export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
+ if (!redirectUri) {
+ return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
+ }
+
+ try {
+ const url = new URL(redirectUri)
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
+ const path = url.pathname || OAUTH_CALLBACK_PATH
+ return { port, path }
+ } catch {
+ return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
+ }
+}
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index bdeef9823f..1e127fae54 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
"gpt-5.4",
"gpt-5.4-mini",
])
- for (const modelId of Object.keys(provider.models)) {
+ for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
- if (allowedModels.has(modelId)) continue
+ if (allowedModels.has(model.api.id)) continue
delete provider.models[modelId]
}
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index 23f61d804e..2d787588b0 100644
--- a/packages/opencode/src/provider/models.ts
+++ b/packages/opencode/src/provider/models.ts
@@ -22,6 +22,27 @@ export namespace ModelsDev {
)
const ttl = 5 * 60 * 1000
+ type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
+
+ const JsonValue: z.ZodType = z.lazy(() =>
+ z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
+ )
+
+ const Cost = z.object({
+ input: z.number(),
+ output: z.number(),
+ cache_read: z.number().optional(),
+ cache_write: z.number().optional(),
+ context_over_200k: z
+ .object({
+ input: z.number(),
+ output: z.number(),
+ cache_read: z.number().optional(),
+ cache_write: z.number().optional(),
+ })
+ .optional(),
+ })
+
export const Model = z.object({
id: z.string(),
name: z.string(),
@@ -41,22 +62,7 @@ export namespace ModelsDev {
.strict(),
])
.optional(),
- cost: z
- .object({
- input: z.number(),
- output: z.number(),
- cache_read: z.number().optional(),
- cache_write: z.number().optional(),
- context_over_200k: z
- .object({
- input: z.number(),
- output: z.number(),
- cache_read: z.number().optional(),
- cache_write: z.number().optional(),
- })
- .optional(),
- })
- .optional(),
+ cost: Cost.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
@@ -68,7 +74,24 @@ export namespace ModelsDev {
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
- experimental: z.boolean().optional(),
+ experimental: z
+ .object({
+ modes: z
+ .record(
+ z.string(),
+ z.object({
+ cost: Cost.optional(),
+ provider: z
+ .object({
+ body: z.record(z.string(), JsonValue).optional(),
+ headers: z.record(z.string(), z.string()).optional(),
+ })
+ .optional(),
+ }),
+ )
+ .optional(),
+ })
+ .optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
})
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 8d5c9f2ced..004fb77f91 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -926,6 +926,28 @@ export namespace Provider {
export class Service extends ServiceMap.Service()("@opencode/Provider") {}
+ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
+ const result: Model["cost"] = {
+ input: c?.input ?? 0,
+ output: c?.output ?? 0,
+ cache: {
+ read: c?.cache_read ?? 0,
+ write: c?.cache_write ?? 0,
+ },
+ }
+ if (c?.context_over_200k) {
+ result.experimentalOver200K = {
+ cache: {
+ read: c.context_over_200k.cache_read ?? 0,
+ write: c.context_over_200k.cache_write ?? 0,
+ },
+ input: c.context_over_200k.input,
+ output: c.context_over_200k.output,
+ }
+ }
+ return result
+ }
+
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const m: Model = {
id: ModelID.make(model.id),
@@ -940,24 +962,7 @@ export namespace Provider {
status: model.status ?? "active",
headers: {},
options: {},
- cost: {
- input: model.cost?.input ?? 0,
- output: model.cost?.output ?? 0,
- cache: {
- read: model.cost?.cache_read ?? 0,
- write: model.cost?.cache_write ?? 0,
- },
- experimentalOver200K: model.cost?.context_over_200k
- ? {
- cache: {
- read: model.cost.context_over_200k.cache_read ?? 0,
- write: model.cost.context_over_200k.cache_write ?? 0,
- },
- input: model.cost.context_over_200k.input,
- output: model.cost.context_over_200k.output,
- }
- : undefined,
- },
+ cost: cost(model.cost),
limit: {
context: model.limit.context,
input: model.limit.input,
@@ -994,13 +999,31 @@ export namespace Provider {
}
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
+ const models: Record = {}
+ for (const [key, model] of Object.entries(provider.models)) {
+ models[key] = fromModelsDevModel(provider, model)
+ for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
+ const id = `${model.id}-${mode}`
+ const m = fromModelsDevModel(provider, model)
+ m.id = ModelID.make(id)
+ m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
+ if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
+ // convert body params to camelCase for ai sdk compatibility
+ if (opts.provider?.body)
+ m.options = Object.fromEntries(
+ Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
+ )
+ if (opts.provider?.headers) m.headers = opts.provider.headers
+ models[id] = m
+ }
+ }
return {
id: ProviderID.make(provider.id),
source: "custom",
name: provider.name,
env: provider.env ?? [],
options: {},
- models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
+ models,
}
}
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 68be1a6a5a..b57ed9d47c 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -121,7 +121,6 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
- log.info("SEARCH", { url: c.req.url })
const session = await Session.get(sessionID)
return c.json(session)
},
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 65032de962..8e1ed9dcc9 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -12,7 +12,7 @@ import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { SyncEvent } from "../sync"
import type { SQL } from "../storage/db"
-import { SessionTable } from "./session.sql"
+import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
@@ -345,6 +345,11 @@ export namespace Session {
messageID: MessageID
partID: PartID
}) => Effect.Effect
+ readonly getPart: (input: {
+ sessionID: SessionID
+ messageID: MessageID
+ partID: PartID
+ }) => Effect.Effect
readonly updatePart: (part: T) => Effect.Effect
readonly updatePartDelta: (input: {
sessionID: SessionID
@@ -492,6 +497,29 @@ export namespace Session {
return part
}).pipe(Effect.withSpan("Session.updatePart"))
+ const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
+ const row = Database.use((db) =>
+ db
+ .select()
+ .from(PartTable)
+ .where(
+ and(
+ eq(PartTable.session_id, input.sessionID),
+ eq(PartTable.message_id, input.messageID),
+ eq(PartTable.id, input.partID),
+ ),
+ )
+ .get(),
+ )
+ if (!row) return
+ return {
+ ...row.data,
+ id: row.id,
+ sessionID: row.session_id,
+ messageID: row.message_id,
+ } as MessageV2.Part
+ })
+
const create = Effect.fn("Session.create")(function* (input?: {
parentID?: SessionID
title?: string
@@ -675,6 +703,7 @@ export namespace Session {
removeMessage,
removePart,
updatePart,
+ getPart,
updatePartDelta,
initialize,
})
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 78604fbf78..61c159646d 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -751,16 +751,32 @@ export namespace MessageV2 {
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
- if (part.state.status === "error")
- assistantMessage.parts.push({
- type: ("tool-" + part.tool) as `tool-${string}`,
- state: "output-error",
- toolCallId: part.callID,
- input: part.state.input,
- errorText: part.state.error,
- ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
- })
+ if (part.state.status === "error") {
+ const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
+ if (typeof output === "string") {
+ assistantMessage.parts.push({
+ type: ("tool-" + part.tool) as `tool-${string}`,
+ state: "output-available",
+ toolCallId: part.callID,
+ input: part.state.input,
+ output,
+ ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+ ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ })
+ } else {
+ assistantMessage.parts.push({
+ type: ("tool-" + part.tool) as `tool-${string}`,
+ state: "output-error",
+ toolCallId: part.callID,
+ input: part.state.input,
+ errorText: part.state.error,
+ ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+ ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ })
+ }
+ }
+ // Handle pending/running tool calls to prevent dangling tool_use blocks
+ // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8e4225fed3..225961aef0 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -18,6 +18,7 @@ import { SessionStatus } from "./status"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
+import { isRecord } from "@/util/record"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -175,12 +176,22 @@ export namespace SessionProcessor {
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
}
- const match = ctx.toolcalls[value.toolCallId]
- if (!match) return
+ const pointer = ctx.toolcalls[value.toolCallId]
+ const match = yield* session.getPart({
+ partID: pointer.id,
+ messageID: pointer.messageID,
+ sessionID: pointer.sessionID,
+ })
+ if (!match || match.type !== "tool") return
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
...match,
tool: value.toolName,
- state: { status: "running", input: value.input, time: { start: Date.now() } },
+ state: {
+ ...match.state,
+ status: "running",
+ input: value.input,
+ time: { start: Date.now() },
+ },
metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata,
@@ -236,6 +247,7 @@ export namespace SessionProcessor {
case "tool-error": {
const match = ctx.toolcalls[value.toolCallId]
if (!match || match.state.status !== "running") return
+
yield* session.updatePart({
...match,
state: {
@@ -351,7 +363,10 @@ export namespace SessionProcessor {
},
{ text: ctx.currentText.text },
)).text
- ctx.currentText.time = { start: Date.now(), end: Date.now() }
+ {
+ const end = Date.now()
+ ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
+ }
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePart(ctx.currentText)
ctx.currentText = undefined
@@ -398,19 +413,21 @@ export namespace SessionProcessor {
}
ctx.reasoningMap = {}
- const parts = MessageV2.parts(ctx.assistantMessage.id)
- for (const part of parts) {
- if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
+ for (const part of Object.values(ctx.toolcalls)) {
+ const end = Date.now()
+ const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
yield* session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
- time: { start: Date.now(), end: Date.now() },
+ metadata: { ...metadata, interrupted: true },
+ time: { start: "time" in part.state ? part.state.time.start : end, end },
},
})
}
+ ctx.toolcalls = {}
ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage)
})
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index e9bd5bcd56..19f0850ff4 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -743,7 +743,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} satisfies MessageV2.TextPart)
})
- const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
+ const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
if (session.revert) {
@@ -1507,7 +1507,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
- Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
+ MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
@@ -1577,7 +1577,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
function* (input: ShellInput) {
const s = yield* InstanceState.get(state)
const runner = getRunner(s.runners, input.sessionID)
- return yield* runner.startShell((signal) => shellImpl(input, signal))
+ return yield* runner.startShell(shellImpl(input))
},
)
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 73b55a2fba..b97b53bb9f 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -9,6 +9,7 @@ import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
+import { Log } from "@/util/log"
const id = "task"
diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts
index 9dc395876e..a91df76ebf 100644
--- a/packages/opencode/test/effect/runner.test.ts
+++ b/packages/opencode/test/effect/runner.test.ts
@@ -250,7 +250,7 @@ describe("Runner", () => {
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make(s)
- const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done"))
+ const result = yield* runner.startShell(Effect.succeed("shell-done"))
expect(result).toBe("shell-done")
expect(runner.busy).toBe(false)
}),
@@ -264,7 +264,7 @@ describe("Runner", () => {
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
- const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit)
+ const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel
@@ -279,12 +279,10 @@ describe("Runner", () => {
const runner = Runner.make(s)
const gate = yield* Deferred.make()
- const sh = yield* runner
- .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first")))
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
- const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit)
+ const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
yield* Deferred.succeed(gate, undefined)
@@ -302,37 +300,26 @@ describe("Runner", () => {
},
})
- const sh = yield* runner
- .startShell((signal) =>
- Effect.promise(
- () =>
- new Promise((resolve) => {
- signal.addEventListener("abort", () => resolve("aborted"), { once: true })
- }),
- ),
- )
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
- const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit)
+ const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel
const done = yield* Fiber.await(sh)
- expect(Exit.isSuccess(done)).toBe(true)
+ expect(Exit.isFailure(done)).toBe(true)
}),
)
it.live(
- "cancel interrupts shell that ignores abort signal",
+ "cancel interrupts shell",
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make(s)
const gate = yield* Deferred.make()
- const sh = yield* runner
- .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored")))
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
@@ -356,9 +343,7 @@ describe("Runner", () => {
const runner = Runner.make(s)
const gate = yield* Deferred.make()
- const sh = yield* runner
- .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result")))
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
expect(runner.state._tag).toBe("Shell")
@@ -384,9 +369,7 @@ describe("Runner", () => {
const calls = yield* Ref.make(0)
const gate = yield* Deferred.make()
- const sh = yield* runner
- .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell")))
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const work = Effect.gen(function* () {
@@ -414,16 +397,7 @@ describe("Runner", () => {
const runner = Runner.make(s)
const gate = yield* Deferred.make()
- const sh = yield* runner
- .startShell((signal) =>
- Effect.promise(
- () =>
- new Promise((resolve) => {
- signal.addEventListener("abort", () => resolve("aborted"), { once: true })
- }),
- ),
- )
- .pipe(Effect.forkChild)
+ const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
@@ -478,7 +452,7 @@ describe("Runner", () => {
const runner = Runner.make(s, {
onBusy: Ref.update(count, (n) => n + 1),
})
- yield* runner.startShell((_signal) => Effect.succeed("done"))
+ yield* runner.startShell(Effect.succeed("done"))
expect(yield* Ref.get(count)).toBe(1)
}),
)
@@ -509,9 +483,7 @@ describe("Runner", () => {
const runner = Runner.make(s)
const gate = yield* Deferred.make()
- const fiber = yield* runner
- .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok")))
- .pipe(Effect.forkChild)
+ const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
expect(runner.busy).toBe(true)
diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts
new file mode 100644
index 0000000000..58a4fa8c86
--- /dev/null
+++ b/packages/opencode/test/mcp/oauth-callback.test.ts
@@ -0,0 +1,34 @@
+import { test, expect, describe, afterEach } from "bun:test"
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
+import { parseRedirectUri } from "../../src/mcp/oauth-provider"
+
+describe("parseRedirectUri", () => {
+ test("returns defaults when no URI provided", () => {
+ const result = parseRedirectUri()
+ expect(result.port).toBe(19876)
+ expect(result.path).toBe("/mcp/oauth/callback")
+ })
+
+ test("parses port and path from URI", () => {
+ const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
+ expect(result.port).toBe(8080)
+ expect(result.path).toBe("/oauth/callback")
+ })
+
+ test("returns defaults for invalid URI", () => {
+ const result = parseRedirectUri("not-a-valid-url")
+ expect(result.port).toBe(19876)
+ expect(result.path).toBe("/mcp/oauth/callback")
+ })
+})
+
+describe("McpOAuthCallback.ensureRunning", () => {
+ afterEach(async () => {
+ await McpOAuthCallback.stop()
+ })
+
+ test("starts server with custom redirectUri port and path", async () => {
+ await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
+ expect(McpOAuthCallback.isRunning()).toBe(true)
+ })
+})
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 88e9ea64c9..9cadc391a1 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
import { Plugin } from "../../src/plugin/index"
+import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
@@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => {
})
})
+test("mode cost preserves over-200k pricing from base model", () => {
+ const provider = {
+ id: "openai",
+ name: "OpenAI",
+ env: [],
+ api: "https://api.openai.com/v1",
+ models: {
+ "gpt-5.4": {
+ id: "gpt-5.4",
+ name: "GPT-5.4",
+ family: "gpt",
+ release_date: "2026-03-05",
+ attachment: true,
+ reasoning: true,
+ temperature: false,
+ tool_call: true,
+ cost: {
+ input: 2.5,
+ output: 15,
+ cache_read: 0.25,
+ context_over_200k: {
+ input: 5,
+ output: 22.5,
+ cache_read: 0.5,
+ },
+ },
+ limit: {
+ context: 1_050_000,
+ input: 922_000,
+ output: 128_000,
+ },
+ experimental: {
+ modes: {
+ fast: {
+ cost: {
+ input: 5,
+ output: 30,
+ cache_read: 0.5,
+ },
+ provider: {
+ body: {
+ service_tier: "priority",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ } as ModelsDev.Provider
+
+ const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
+ expect(model.cost.input).toEqual(5)
+ expect(model.cost.output).toEqual(30)
+ expect(model.cost.cache.read).toEqual(0.5)
+ expect(model.cost.cache.write).toEqual(0)
+ expect(model.options["serviceTier"]).toEqual("priority")
+ expect(model.cost.experimentalOver200K).toEqual({
+ input: 5,
+ output: 22.5,
+ cache: {
+ read: 0.5,
+ write: 0,
+ },
+ })
+})
+
test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 3634d6fb7e..64a5d3e4b2 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("forwards partial bash output for aborted tool calls", async () => {
+ const userID = "m-user"
+ const assistantID = "m-assistant"
+ const output = [
+ "31403",
+ "12179",
+ "4575",
+ "",
+ "",
+ "User aborted the command",
+ "",
+ ].join("\n")
+
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID),
+ parts: [
+ {
+ ...basePart(assistantID, "a1"),
+ type: "tool",
+ callID: "call-1",
+ tool: "bash",
+ state: {
+ status: "error",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ error: "Tool execution aborted",
+ metadata: { interrupted: true, output },
+ time: { start: 0, end: 1 },
+ },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "bash",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ providerExecuted: undefined,
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call-1",
+ toolName: "bash",
+ output: { type: "text", value: output },
+ },
+ ],
+ },
+ ])
+ })
+
test("filters assistant messages with non-abort errors", async () => {
const assistantID = "m-assistant"
diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts
index 86149272bc..a3b335b6da 100644
--- a/packages/opencode/test/session/processor-effect.test.ts
+++ b/packages/opencode/test/session/processor-effect.test.ts
@@ -21,7 +21,7 @@ import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
-import { reply, TestLLMServer } from "../lib/llm-server"
+import { raw, reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
@@ -218,6 +218,93 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
),
)
+it.live("session.processor effect tests preserve text start time", () =>
+ provideTmpdirServer(
+ ({ dir, llm }) =>
+ Effect.gen(function* () {
+ const gate = defer()
+ const { processors, session, provider } = yield* boot()
+
+ yield* llm.push(
+ raw({
+ head: [
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: { role: "assistant" } }],
+ },
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: { content: "hello" } }],
+ },
+ ],
+ wait: gate.promise,
+ tail: [
+ {
+ id: "chatcmpl-test",
+ object: "chat.completion.chunk",
+ choices: [{ delta: {}, finish_reason: "stop" }],
+ },
+ ],
+ }),
+ )
+
+ const chat = yield* session.create({})
+ const parent = yield* user(chat.id, "hi")
+ const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
+ const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
+ const handle = yield* processors.create({
+ assistantMessage: msg,
+ sessionID: chat.id,
+ model: mdl,
+ })
+
+ const run = yield* handle
+ .process({
+ user: {
+ id: parent.id,
+ sessionID: chat.id,
+ role: "user",
+ time: parent.time,
+ agent: parent.agent,
+ model: { providerID: ref.providerID, modelID: ref.modelID },
+ } satisfies MessageV2.User,
+ sessionID: chat.id,
+ model: mdl,
+ agent: agent(),
+ system: [],
+ messages: [{ role: "user", content: "hi" }],
+ tools: {},
+ })
+ .pipe(Effect.forkChild)
+
+ yield* Effect.promise(async () => {
+ const stop = Date.now() + 500
+ while (Date.now() < stop) {
+ const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
+ if (text?.time?.start) return
+ await Bun.sleep(10)
+ }
+ throw new Error("timed out waiting for text part")
+ })
+ yield* Effect.sleep("20 millis")
+ gate.resolve()
+
+ const exit = yield* Fiber.await(run)
+ const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
+
+ expect(Exit.isSuccess(exit)).toBe(true)
+ expect(text?.text).toBe("hello")
+ expect(text?.time?.start).toBeDefined()
+ expect(text?.time?.end).toBeDefined()
+ if (!text?.time?.start || !text.time.end) return
+ expect(text.time.start).toBeLessThan(text.time.end)
+ }),
+ { git: true, config: (url) => providerCfg(url) },
+ ),
+)
+
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
provideTmpdirServer(
({ dir, llm }) =>
@@ -604,6 +691,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
expect(call?.state.status).toBe("error")
if (call?.state.status === "error") {
expect(call.state.error).toBe("Tool execution aborted")
+ expect(call.state.metadata?.interrupted).toBe(true)
expect(call.state.time.end).toBeDefined()
}
}),
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 0b52bbd47c..adfee9c65c 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index a3aa709a71..3dbab2c54a 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 4f226e60cf..38f4db09a3 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1375,6 +1375,10 @@ export type McpOAuthConfig = {
* OAuth scopes to request during authorization
*/
scope?: string
+ /**
+ * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).
+ */
+ redirectUri?: string
}
export type McpRemoteConfig = {
diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts
index d514784bc2..9615eacc7a 100644
--- a/packages/sdk/js/src/v2/index.ts
+++ b/packages/sdk/js/src/v2/index.ts
@@ -6,7 +6,6 @@ import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
-import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index a0672df2d7..366bc1fc79 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -10882,6 +10882,10 @@
"scope": {
"description": "OAuth scopes to request during authorization",
"type": "string"
+ },
+ "redirectUri": {
+ "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/packages/slack/package.json b/packages/slack/package.json
index 4e3e54800b..d23b740cda 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8de6ea0d43..01c6fae869 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.4.1",
+ "version": "1.4.2",
"type": "module",
"license": "MIT",
"exports": {
diff --git a/packages/util/package.json b/packages/util/package.json
index 105098595e..53c170f144 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
- "version": "1.4.1",
+ "version": "1.4.2",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/web/package.json b/packages/web/package.json
index de36ca6574..d7f627c57a 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
- "version": "1.4.1",
+ "version": "1.4.2",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index afe506d0ea..1d0472d787 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.4.1",
+ "version": "1.4.2",
"publisher": "sst-dev",
"repository": {
"type": "git",