diff options
37 files changed, 1076 insertions, 139 deletions
@@ -69,3 +69,7 @@ | 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) | | 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) | | 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) | +| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) | +| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) | +| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) | +| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) | diff --git a/cloud/app/package.json b/cloud/app/package.json index 6fa9a7672..4b30c3cff 100644 --- a/cloud/app/package.json +++ b/cloud/app/package.json @@ -7,7 +7,7 @@ "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", "build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json", "start": "vinxi start", - "version": "0.6.4" + "version": "0.6.6" }, "dependencies": { "@ibm/plex": "6.4.1", diff --git a/cloud/app/src/routes/zen/v1/chat/completions.ts b/cloud/app/src/routes/zen/v1/chat/completions.ts index 9805176ce..9765ea08f 100644 --- a/cloud/app/src/routes/zen/v1/chat/completions.ts +++ b/cloud/app/src/routes/zen/v1/chat/completions.ts @@ -22,7 +22,7 @@ const MODELS = { // headerMappings: {}, // }, "qwen/qwen3-coder": { - id: "qwen/qwen3-coder", + id: "qwen/qwen3-coder" as const, auth: true, api: "https://inference.baseten.co", apiKey: Resource.BASETEN_API_KEY.value, @@ -37,7 +37,7 @@ const MODELS = { headerMappings: {}, }, "grok-code": { - id: "x-ai/grok-code-fast-1", + id: "x-ai/grok-code-fast-1" as const, auth: false, api: "https://api.x.ai", apiKey: Resource.XAI_API_KEY.value, @@ -68,6 +68,11 @@ export async function POST(input: APIEvent) { try { const url = new URL(input.request.url) const body = await input.request.json() + logMetric({ + is_tream: !!body.stream, + session: input.request.headers.get("x-opencode-session"), + request: input.request.headers.get("x-opencode-request"), + }) const MODEL = validateModel() const apiKey = await authenticate() const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "") @@ -106,9 +111,11 @@ export async function POST(input: APIEvent) { // Handle non-streaming response if (!body.stream) { - const body = await res.json() - await trackUsage(body) - return new Response(JSON.stringify(body), { + const json = await res.json() + const body = JSON.stringify(json) + logMetric({ response_length: body.length }) + await trackUsage(json) + return new Response(body, { status: res.status, statusText: res.statusText, headers: resHeaders, @@ -121,16 +128,26 @@ export async function POST(input: APIEvent) { const reader = res.body?.getReader() const decoder = new TextDecoder() let buffer = "" + let responseLength = 0 + let startTimestamp = Date.now() + let receivedFirstByte = false function pump(): Promise<void> { return ( reader?.read().then(async ({ done, value }) => { if (done) { + logMetric({ response_length: responseLength }) c.close() return } + if (!receivedFirstByte) { + receivedFirstByte = true + logMetric({ time_to_first_byte: Date.now() - startTimestamp }) + } + buffer += decoder.decode(value, { stream: true }) + responseLength += value.length const parts = buffer.split("\n\n") buffer = parts.pop() ?? "" @@ -169,7 +186,9 @@ export async function POST(input: APIEvent) { if (!(body.model in MODELS)) { throw new ModelError(`Model ${body.model} not supported`) } - return MODELS[body.model as keyof typeof MODELS] + const model = MODELS[body.model as keyof typeof MODELS] + logMetric({ model: model.id }) + return model } async function authenticate() { @@ -190,9 +209,12 @@ export async function POST(input: APIEvent) { ) if (!key) throw new AuthError("Invalid API key.") + logMetric({ + api_key: key.id, + workspace: key.workspaceID, + }) return key } catch (e) { - console.log(e) // ignore error if model does not require authentication if (!MODEL.auth) return throw e @@ -216,10 +238,6 @@ export async function POST(input: APIEvent) { } async function trackUsage(chunk: any) { - console.log(`trackUsage ${apiKey}`) - - if (!apiKey) return - const usage = chunk.usage const inputTokens = usage.prompt_tokens ?? 0 const outputTokens = usage.completion_tokens ?? 0 @@ -228,14 +246,30 @@ export async function POST(input: APIEvent) { //const cacheWriteTokens = providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0 const cacheWriteTokens = 0 - const inputCost = MODEL.cost.input * inputTokens - const outputCost = MODEL.cost.output * outputTokens - const reasoningCost = MODEL.cost.reasoning * reasoningTokens - const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens - const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens - const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100 - const cost = isFree ? 0 : centsToMicroCents(costInCents) + const inputCost = MODEL.cost.input * inputTokens * 100 + const outputCost = MODEL.cost.output * outputTokens * 100 + const reasoningCost = MODEL.cost.reasoning * reasoningTokens * 100 + const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens * 100 + const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens * 100 + const totalCostInCent = inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost + + logMetric({ + "tokens.input": inputTokens, + "tokens.output": outputTokens, + "tokens.reasoning": reasoningTokens, + "tokens.cache_read": cacheReadTokens, + "tokens.cache_write": cacheWriteTokens, + "cost.input": Math.round(inputCost), + "cost.output": Math.round(outputCost), + "cost.reasoning": Math.round(reasoningCost), + "cost.cache_read": Math.round(cacheReadCost), + "cost.cache_write": Math.round(cacheWriteCost), + "cost.total": Math.round(totalCostInCent), + }) + if (!apiKey) return + + const cost = isFree ? 0 : centsToMicroCents(totalCostInCent) await Database.transaction(async (tx) => { await tx.insert(UsageTable).values({ workspaceID: apiKey.workspaceID, @@ -264,47 +298,18 @@ export async function POST(input: APIEvent) { ) } } catch (error: any) { - if (error instanceof AuthError) { - return new Response( - JSON.stringify({ - error: { - message: error.message, - type: "invalid_request_error", - param: null, - code: "unauthorized", - }, - }), - { - status: 401, - }, - ) - } + logMetric({ + "error.type": error.constructor.name, + "error.message": error.message, + }) - if (error instanceof CreditsError) { - return new Response( - JSON.stringify({ - error: { - message: error.message, - type: "insufficient_quota", - param: null, - code: "insufficient_quota", - }, - }), - { - status: 401, - }, - ) - } + if (error instanceof AuthError || error instanceof CreditsError || error instanceof ModelError) + return new Response(JSON.stringify({ error: { message: error.message } }), { status: 401 }) - if (error instanceof ModelError) { - return new Response(JSON.stringify({ error: { message: error.message } }), { - status: 401, - }) - } + return new Response(JSON.stringify({ error: { message: error.message } }), { status: 500 }) + } - console.log(error) - return new Response(JSON.stringify({ error: { message: error.message } }), { - status: 500, - }) + function logMetric(values: Record<string, any>) { + console.log(`_metric:${JSON.stringify(values)}`) } } diff --git a/cloud/core/package.json b/cloud/core/package.json index adc0776e2..c32a08abf 100644 --- a/cloud/core/package.json +++ b/cloud/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/cloud-core", - "version": "0.6.4", + "version": "0.6.6", "private": true, "type": "module", "dependencies": { diff --git a/cloud/function/package.json b/cloud/function/package.json index 14518efa6..21aa49b80 100644 --- a/cloud/function/package.json +++ b/cloud/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/cloud-function", - "version": "0.6.4", + "version": "0.6.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/cloud/function/src/log-processor.ts b/cloud/function/src/log-processor.ts new file mode 100644 index 000000000..70fcf5a44 --- /dev/null +++ b/cloud/function/src/log-processor.ts @@ -0,0 +1,49 @@ +import { Resource } from "@opencode/cloud-resource" +import type { TraceItem } from "@cloudflare/workers-types" + +export default { + async tail(events: TraceItem[]) { + for (const event of events) { + if (!event.event) continue + if (!("request" in event.event)) continue + if (event.event.request.method !== "POST") continue + + const url = new URL(event.event.request.url) + if (url.pathname !== "/zen/v1/chat/completions") return + + let metrics = { + event_type: "completions", + "cf.continent": event.event.request.cf?.continent, + "cf.country": event.event.request.cf?.country, + "cf.city": event.event.request.cf?.city, + "cf.region": event.event.request.cf?.region, + "cf.latitude": event.event.request.cf?.latitude, + "cf.longitude": event.event.request.cf?.longitude, + "cf.timezone": event.event.request.cf?.timezone, + duration: event.wallTime, + request_length: parseInt(event.event.request.headers["content-length"] ?? "0"), + status: event.event.response?.status ?? 0, + ip: event.event.request.headers["x-real-ip"], + } + for (const log of event.logs) { + for (const message of log.message) { + if (!message.startsWith("_metric:")) continue + metrics = { ...metrics, ...JSON.parse(message.slice(8)) } + } + } + console.log(JSON.stringify(metrics, null, 2)) + + const ret = await fetch("https://api.honeycomb.io/1/events/zen", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Honeycomb-Event-Time": (event.eventTimestamp ?? Date.now()).toString(), + "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value, + }, + body: JSON.stringify(metrics), + }) + console.log(ret.status) + console.log(await ret.text()) + } + }, +} diff --git a/cloud/function/sst-env.d.ts b/cloud/function/sst-env.d.ts index 5478b4334..efb047ff5 100644 --- a/cloud/function/sst-env.d.ts +++ b/cloud/function/sst-env.d.ts @@ -50,6 +50,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "HONEYCOMB_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -76,6 +80,7 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket + "LogProcessor": cloudflare.Service } } diff --git a/cloud/resource/sst-env.d.ts b/cloud/resource/sst-env.d.ts index 5478b4334..efb047ff5 100644 --- a/cloud/resource/sst-env.d.ts +++ b/cloud/resource/sst-env.d.ts @@ -50,6 +50,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "HONEYCOMB_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -76,6 +80,7 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket + "LogProcessor": cloudflare.Service } } diff --git a/cloud/scripts/package.json b/cloud/scripts/package.json index 85896ea4e..0a773fc45 100644 --- a/cloud/scripts/package.json +++ b/cloud/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/cloud-scripts", - "version": "0.6.4", + "version": "0.6.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/infra/cloud.ts b/infra/cloud.ts index 74690669e..9cb5a83f1 100644 --- a/infra/cloud.ts +++ b/infra/cloud.ts @@ -7,7 +7,7 @@ import { domain } from "./stage" const cluster = planetscale.getDatabaseOutput({ name: "opencode", - organization: "sst", + organization: "anomalyco", }) const branch = @@ -114,6 +114,15 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { // CONSOLE //////////////// +let logProcessor +if ($app.stage === "production" || $app.stage === "frank") { + const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY") + logProcessor = new sst.cloudflare.Worker("LogProcessor", { + handler: "cloud/function/src/log-processor.ts", + link: [HONEYCOMB_API_KEY], + }) +} + new sst.cloudflare.x.SolidStart("Console", { domain, path: "cloud/app", @@ -135,9 +144,8 @@ new sst.cloudflare.x.SolidStart("Console", { server: { transform: { worker: { - placement: { - mode: "smart", - }, + placement: { mode: "smart" }, + tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [], }, }, }, diff --git a/packages/function/package.json b/packages/function/package.json index c5a55f8e2..76ebfcbc1 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/function", - "version": "0.6.4", + "version": "0.6.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 5478b4334..efb047ff5 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -50,6 +50,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "HONEYCOMB_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -76,6 +80,7 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket + "LogProcessor": cloudflare.Service } } diff --git a/packages/opencode/bin/opencode.cmd b/packages/opencode/bin/opencode.cmd index 3a4ef3e72..775bfe688 100644 --- a/packages/opencode/bin/opencode.cmd +++ b/packages/opencode/bin/opencode.cmd @@ -52,5 +52,7 @@ echo It seems that your package manager failed to install the right version of t exit /b 1 :execute -rem Execute the binary with all arguments -"%resolved%" %* +rem Execute the binary with all arguments in the same console window +rem Use start /b /wait to ensure it runs in the current shell context for all shells +start /b /wait "" "%resolved%" %* +exit /b %ERRORLEVEL% diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8939d106f..65c08641d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.6.4", + "version": "0.6.6", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 2c6974123..40796562c 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -70,6 +70,11 @@ function findBinary() { function main() { try { + if (os.platform() === "win32") { + console.log("Windows detected, skipping postinstall") + return + } + const binaryPath = findBinary() const binScript = path.join(__dirname, "bin", "opencode") diff --git a/packages/opencode/script/preinstall.mjs b/packages/opencode/script/preinstall.mjs new file mode 100644 index 000000000..49c8db5e5 --- /dev/null +++ b/packages/opencode/script/preinstall.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import fs from "fs" +import path from "path" +import os from "os" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function main() { + if (os.platform() !== "win32") { + console.log("Non-Windows platform detected, skipping preinstall") + return + } + + const binDir = path.join(__dirname, "bin") + const unixScript = path.join(binDir, "opencode") + + console.log("Windows detected: Configuring bin scripts for Windows") + + if (fs.existsSync(unixScript)) { + console.log("Removing Unix shell script from bin/") + fs.unlinkSync(unixScript) + } +} + +try { + main() +} catch (error) { + console.error("Preinstall script error:", error.message) + process.exit(0) +} diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 33ddeff05..0de48fe67 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -66,6 +66,7 @@ for (const [os, arch] of targets) { await $`mkdir -p ./dist/${pkg.name}` await $`cp -r ./bin ./dist/${pkg.name}/bin` +await $`cp ./script/preinstall.mjs ./dist/${pkg.name}/preinstall.mjs` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` await Bun.file(`./dist/${pkg.name}/package.json`).write( JSON.stringify( @@ -75,6 +76,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write( [pkg.name]: `./bin/${pkg.name}`, }, scripts: { + preinstall: "node ./preinstall.mjs", postinstall: "node ./postinstall.mjs", }, version, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 457890d54..dda220be4 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -156,10 +156,10 @@ export namespace File { const diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() if (diff.trim()) { const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() - const diff = structuredPatch(file, file, original, content, "old", "new", { + const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, }) - const patch = formatPatch(diff) + const diff = formatPatch(patch) return { content, patch, diff } } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e70692103..6e224abd7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -84,7 +84,11 @@ const cli = yargs(hideBin(process.argv)) .command(ExportCommand) .command(GithubCommand) .fail((msg) => { - if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) { + if ( + msg.startsWith("Unknown argument") || + msg.startsWith("Not enough non-option arguments") || + msg.startsWith("Invalid values:") + ) { cli.showHelp("log") } process.exit(1) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index a9e9803fc..0f0689b50 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -7,6 +7,7 @@ import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" +import { ToolRegistry } from "../tool/registry" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -24,6 +25,8 @@ export namespace Plugin { worktree: Instance.worktree, directory: Instance.directory, $: Bun.$, + Tool: await import("../tool/tool").then(m => m.Tool), + z: await import("zod").then(m => m.z), } const plugins = [...(config.plugin ?? [])] if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { @@ -75,6 +78,11 @@ export namespace Plugin { const config = await Config.get() for (const hook of hooks) { await hook.config?.(config) + // Let plugins register tools at startup + await hook["tool.register"]?.({}, { + registerHTTP: ToolRegistry.registerHTTP, + register: ToolRegistry.register + }) } Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 184c2c950..37d7aa7c0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import path from "path" import { Config } from "../config/config" import { mergeDeep, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" @@ -9,6 +10,7 @@ import { ModelsDev } from "./models" import { NamedError } from "../util/error" import { Auth } from "../auth" import { Instance } from "../project/instance" +import { Global } from "../global" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -79,7 +81,8 @@ export namespace Provider { switch (regionPrefix) { case "us": { const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m)) - if (modelRequiresPrefix) { + const isGovCloud = region.startsWith("us-gov") + if (modelRequiresPrefix && !isGovCloud) { modelID = `${regionPrefix}.${modelID}` } break @@ -420,6 +423,43 @@ export namespace Provider { export async function defaultModel() { const cfg = await Config.get() if (cfg.model) return parseModel(cfg.model) + + // this will be adjusted when migration to opentui is complete, + // for now we just read the tui state toml file directly + // + // NOTE: cannot just import file as toml without cleaning due to lack of + // support for date/time references in Bun toml parser: https://github.com/oven-sh/bun/issues/22426 + const lastused = await Bun.file(path.join(Global.Path.state, "tui")) + .text() + .then((text) => { + // remove the date/time references since Bun toml parser doesn't support yet + const cleaned = text + .split("\n") + .filter((line) => !line.trim().startsWith("last_used =")) + .join("\n") + const state = Bun.TOML.parse(cleaned) as { + recently_used_models?: { + provider_id: string + model_id: string + }[] + } + const models = state?.recently_used_models ?? [] + if (models.length > 0) { + return { + providerID: models[0].provider_id, + modelID: models[0].model_id, + } + } + }) + .catch((error) => { + log.error("failed to find last used model", { + error, + }) + return undefined + }) + + if (lastused) return lastused + const provider = await list() .then((val) => Object.values(val)) .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id))) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 50aa55cba..a9616cfd1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -98,4 +98,18 @@ export namespace ProviderTransform { } return result } + + export function maxOutputTokens(providerID: string, outputLimit: number, options: Record<string, any>): number { + if (providerID === "anthropic") { + const thinking = options["thinking"] + if (typeof thinking === "object" && thinking !== null) { + const type = thinking["type"] + const budgetTokens = thinking["budgetTokens"] + if (type === "enabled" && typeof budgetTokens === "number" && budgetTokens > 0) { + return outputLimit - budgetTokens + } + } + } + return outputLimit + } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9091f0508..108a67ec0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -23,6 +23,8 @@ import { Auth } from "../auth" import { Command } from "../command" import { Global } from "../global" import { ProjectRoute } from "./project" +import { ToolRegistry } from "../tool/registry" +import { zodToJsonSchema } from "zod-to-json-schema" const ERRORS = { 400: { @@ -46,6 +48,29 @@ const ERRORS = { export namespace Server { const log = Log.create({ service: "server" }) + // Schemas for HTTP tool registration + const HttpParamSpec = z + .object({ + type: z.enum(["string", "number", "boolean", "array"]), + description: z.string().optional(), + optional: z.boolean().optional(), + items: z.enum(["string", "number", "boolean"]).optional(), + }) + .openapi({ ref: "HttpParamSpec" }) + + const HttpToolRegistration = z + .object({ + id: z.string(), + description: z.string(), + parameters: z.object({ + type: z.literal("object"), + properties: z.record(HttpParamSpec), + }), + callbackUrl: z.string(), + headers: z.record(z.string(), z.string()).optional(), + }) + .openapi({ ref: "HttpToolRegistration" }) + export const Event = { Connected: Bus.event("server.connected", z.object({})), } @@ -166,6 +191,99 @@ export namespace Server { return c.json(await Config.get()) }, ) + .post( + "/experimental/tool/register", + describeRoute({ + description: "Register a new HTTP callback tool", + operationId: "tool.register", + responses: { + 200: { + description: "Tool registered successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...ERRORS, + }, + }), + zValidator("json", HttpToolRegistration), + async (c) => { + ToolRegistry.registerHTTP(c.req.valid("json")) + return c.json(true) + }, + ) + .get( + "/experimental/tool/ids", + describeRoute({ + description: "List all tool IDs (including built-in and dynamically registered)", + operationId: "tool.ids", + responses: { + 200: { + description: "Tool IDs", + content: { + "application/json": { + schema: resolver(z.array(z.string()).openapi({ ref: "ToolIDs" })), + }, + }, + }, + ...ERRORS, + }, + }), + async (c) => { + return c.json(ToolRegistry.ids()) + }, + ) + .get( + "/experimental/tool", + describeRoute({ + description: "List tools with JSON schema parameters for a provider/model", + operationId: "tool.list", + responses: { + 200: { + description: "Tools", + content: { + "application/json": { + schema: resolver( + z + .array( + z + .object({ + id: z.string(), + description: z.string(), + parameters: z.any(), + }) + .openapi({ ref: "ToolListItem" }), + ) + .openapi({ ref: "ToolList" }), + ), + }, + }, + }, + ...ERRORS, + }, + }), + zValidator( + "query", + z.object({ + provider: z.string(), + model: z.string(), + }), + ), + async (c) => { + const { provider, model } = c.req.valid("query") + const tools = await ToolRegistry.tools(provider, model) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + // Handle both Zod schemas and plain JSON schemas + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, + })), + ) + }, + ) .get( "/path", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 1bee54426..2b49bb74a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -365,6 +365,36 @@ export namespace Session { return part } + async function cleanupRevert(session: Info) { + if (!session.revert) return + const sessionID = session.id + let msgs = await messages(sessionID) + const messageID = session.revert.messageID + const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) + msgs = preserve + for (const msg of remove) { + await Storage.remove(["message", sessionID, msg.info.id]) + await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id }) + } + const last = preserve.at(-1) + if (session.revert.partID && last) { + const partID = session.revert.partID + const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID) + last.parts = preserveParts + for (const part of removeParts) { + await Storage.remove(["part", last.info.id, part.id]) + await Bus.publish(MessageV2.Event.PartRemoved, { + sessionID: sessionID, + messageID: last.info.id, + partID: part.id, + }) + } + } + await update(sessionID, (draft) => { + draft.revert = undefined + }) + } + export const PromptInput = z.object({ sessionID: Identifier.schema("session"), messageID: Identifier.schema("message").optional(), @@ -425,31 +455,7 @@ export namespace Session { // Process revert cleanup first, before creating new messages const session = await get(input.sessionID) if (session.revert) { - let msgs = await messages(input.sessionID) - const messageID = session.revert.messageID - const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) - msgs = preserve - for (const msg of remove) { - await Storage.remove(["message", input.sessionID, msg.info.id]) - await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id }) - } - const last = preserve.at(-1) - if (session.revert.partID && last) { - const partID = session.revert.partID - const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID) - last.parts = preserveParts - for (const part of removeParts) { - await Storage.remove(["part", last.info.id, part.id]) - await Bus.publish(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: last.info.id, - partID: part.id, - }) - } - } - await update(input.sessionID, (draft) => { - draft.revert = undefined - }) + cleanupRevert(session) } const userMsg: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), @@ -691,16 +697,27 @@ export namespace Session { const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true) if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id) - - if (msgs.filter((m) => m.info.role === "user").length === 1 && !session.parentID && isDefaultTitle(session.title)) { + const numRealUserMsgs = msgs.filter( + (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), + ).length + if (numRealUserMsgs === 1 && !session.parentID && isDefaultTitle(session.title)) { const small = (await Provider.getSmallModel(model.providerID)) ?? model + const options = { + ...ProviderTransform.options(small.providerID, small.modelID, input.sessionID), + ...small.info.options, + } + if (small.providerID === "openai") { + options["reasoningEffort"] = "minimal" + } + if (small.providerID === "google") { + options["thinkingConfig"] = { + thinkingBudget: 0, + } + } generateText({ - maxOutputTokens: small.info.reasoning ? 1024 : 20, + maxOutputTokens: small.info.reasoning ? 1500 : 20, providerOptions: { - [model.providerID]: { - ...small.info.options, - ...ProviderTransform.options(small.providerID, small.modelID, input.sessionID), - }, + [model.providerID]: options, }, messages: [ ...SystemPrompt.title(model.providerID).map( @@ -1008,7 +1025,7 @@ export namespace Session { : undefined, maxRetries: 3, activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: outputLimit, + maxOutputTokens: ProviderTransform.maxOutputTokens(model.providerID, outputLimit, params.options), abortSignal: abort.signal, stopWhen: async ({ steps }) => { if (steps.length >= 1000) { @@ -1074,6 +1091,10 @@ export namespace Session { export type ShellInput = z.infer<typeof ShellInput> export async function shell(input: ShellInput) { using abort = lock(input.sessionID) + const session = await get(input.sessionID) + if (session.revert) { + cleanupRevert(session) + } const userMsg: MessageV2.User = { id: Identifier.ascending("message"), sessionID: input.sessionID, @@ -1297,18 +1318,26 @@ export namespace Session { }), ) + const model = await (async () => { + if (command.model) { + return Provider.parseModel(command.model) + } + if (command.agent) { + const agent = await Agent.get(command.agent) + if (agent.model) { + return agent.model + } + } + if (input.model) { + return Provider.parseModel(input.model) + } + return undefined + })() + return prompt({ sessionID: input.sessionID, messageID: input.messageID, - model: (() => { - if (input.model) { - return Provider.parseModel(input.model) - } - if (command.model) { - return Provider.parseModel(command.model) - } - return undefined - })(), + model, agent, parts, }) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 928188c07..f4fd87feb 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -594,6 +594,8 @@ export function replace(content: string, oldString: string, newString: string, r throw new Error("oldString and newString must be different") } + let notFound = true + for (const replacer of [ SimpleReplacer, LineTrimmedReplacer, @@ -608,6 +610,7 @@ export function replace(content: string, oldString: string, newString: string, r for (const search of replacer(content, oldString)) { const index = content.indexOf(search) if (index === -1) continue + notFound = false if (replaceAll) { return content.replaceAll(search, newString) } @@ -616,5 +619,9 @@ export function replace(content: string, oldString: string, newString: string, r return content.substring(0, index) + newString + content.substring(index + search.length) } } - throw new Error("oldString not found in content or was found multiple times") + + if (notFound) { + throw new Error("oldString not found in content") + } + throw new Error("oldString found multiple times and requires more code context to uniquely identify the intended match") } diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index 8bf8844d3..863efb840 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -5,5 +5,6 @@ Usage: - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. -- The edit will FAIL if `oldString` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. +- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. - Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c25b16ed3..f1211bf95 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import type { Agent } from "../agent/agent" +import { Tool } from "./tool" export namespace ToolRegistry { - const ALL = [ + // Built-in tools that ship with opencode + const BUILTIN = [ InvalidTool, BashTool, EditTool, @@ -30,13 +32,103 @@ export namespace ToolRegistry { TaskTool, ] + // Extra tools registered at runtime (via plugins) + const EXTRA: Tool.Info[] = [] + + // Tools registered via HTTP callback (via SDK/API) + const HTTP: Tool.Info[] = [] + + export type HttpParamSpec = { + type: "string" | "number" | "boolean" | "array" + description?: string + optional?: boolean + items?: "string" | "number" | "boolean" + } + export type HttpToolRegistration = { + id: string + description: string + parameters: { + type: "object" + properties: Record<string, HttpParamSpec> + } + callbackUrl: string + headers?: Record<string, string> + } + + function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) { + const shape: Record<string, z.ZodTypeAny> = {} + for (const [key, val] of Object.entries(spec.properties)) { + let base: z.ZodTypeAny + switch (val.type) { + case "string": + base = z.string() + break + case "number": + base = z.number() + break + case "boolean": + base = z.boolean() + break + case "array": + if (!val.items) throw new Error(`array spec for ${key} requires 'items'`) + base = z.array( + val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean(), + ) + break + default: + base = z.any() + } + if (val.description) base = base.describe(val.description) + shape[key] = val.optional ? base.optional() : base + } + return z.object(shape) + } + + export function register(tool: Tool.Info) { + // Prevent duplicates by id (replace existing) + const idx = EXTRA.findIndex((t) => t.id === tool.id) + if (idx >= 0) EXTRA.splice(idx, 1, tool) + else EXTRA.push(tool) + } + + export function registerHTTP(input: HttpToolRegistration) { + const parameters = buildZodFromHttpSpec(input.parameters) + const info = Tool.define(input.id, { + description: input.description, + parameters, + async execute(args) { + const res = await fetch(input.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json", ...(input.headers ?? {}) }, + body: JSON.stringify({ args }), + }) + if (!res.ok) { + throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`) + } + const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> } + return { + title: json.title ?? input.id, + output: json.output ?? "", + metadata: (json.metadata ?? {}) as any, + } + }, + }) + const idx = HTTP.findIndex((t) => t.id === info.id) + if (idx >= 0) HTTP.splice(idx, 1, info) + else HTTP.push(info) + } + + function allTools(): Tool.Info[] { + return [...BUILTIN, ...EXTRA, ...HTTP] + } + export function ids() { - return ALL.map((t) => t.id) + return allTools().map((t) => t.id) } export async function tools(providerID: string, _modelID: string) { const result = await Promise.all( - ALL.map(async (t) => ({ + allTools().map(async (t) => ({ id: t.id, ...(await t.init()), })), @@ -45,21 +137,21 @@ export namespace ToolRegistry { if (providerID === "openai") { return result.map((t) => ({ ...t, - parameters: optionalToNullable(t.parameters), + parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny), })) } if (providerID === "azure") { return result.map((t) => ({ ...t, - parameters: optionalToNullable(t.parameters), + parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny), })) } if (providerID === "google") { return result.map((t) => ({ ...t, - parameters: sanitizeGeminiParameters(t.parameters), + parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny), })) } @@ -68,7 +160,7 @@ export namespace ToolRegistry { export async function enabled( _providerID: string, - modelID: string, + _modelID: string, agent: Agent.Info, ): Promise<Record<string, boolean>> { const result: Record<string, boolean> = {} @@ -86,11 +178,6 @@ export namespace ToolRegistry { result["webfetch"] = false } - if (modelID.toLowerCase().includes("qwen")) { - result["todowrite"] = false - result["todoread"] = false - } - return result } diff --git a/packages/opencode/test/tool/register.test.ts b/packages/opencode/test/tool/register.test.ts new file mode 100644 index 000000000..2a72c119b --- /dev/null +++ b/packages/opencode/test/tool/register.test.ts @@ -0,0 +1,299 @@ +import "zod-openapi/extend" +import { describe, expect, test } from "bun:test" +import path from "path" +import os from "os" +import { Instance } from "../../src/project/instance" + +// Helper to create a Request targeting the in-memory Hono app +function makeRequest(method: string, url: string, body?: any) { + const headers: Record<string, string> = { "content-type": "application/json" } + const init: RequestInit = { method, headers } + if (body !== undefined) init.body = JSON.stringify(body) + return new Request(url, init) +} + +describe("HTTP tool registration API", () => { + test("POST /tool/register then list via /tool/ids and /tool", async () => { + const projectRoot = path.join(__dirname, "../..") + await Instance.provide(projectRoot, async () => { + const { Server } = await import("../../src/server/server") + + const toolSpec = { + id: "http-echo", + description: "Simple echo tool (test-only)", + parameters: { + type: "object" as const, + properties: { + foo: { type: "string" as const, optional: true }, + bar: { type: "number" as const }, + }, + }, + callbackUrl: "http://localhost:9999/echo", + } + + // Register + const registerRes = await Server.App.fetch( + makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec), + ) + expect(registerRes.status).toBe(200) + const ok = await registerRes.json() + expect(ok).toBe(true) + + // IDs should include the new tool + const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids")) + expect(idsRes.status).toBe(200) + const ids = (await idsRes.json()) as string[] + expect(ids).toContain("http-echo") + + // List tools for a provider/model and check JSON Schema shape + const listRes = await Server.App.fetch( + makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"), + ) + expect(listRes.status).toBe(200) + const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }> + const found = list.find((t) => t.id === "http-echo") + expect(found).toBeTruthy() + expect(found!.description).toBe("Simple echo tool (test-only)") + + // Basic JSON Schema checks + expect(found!.parameters?.type).toBe("object") + expect(found!.parameters?.properties?.bar?.type).toBe("number") + + const foo = found!.parameters?.properties?.foo + // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true + const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true + expect(fooIsNullable).toBe(true) + }) + }) +}) + +describe("Plugin tool.register hook", () => { + test("Plugin registers tool during Plugin.init()", async () => { + // Create a temporary project directory with opencode.json that points to our plugin + const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`) + await Bun.$`mkdir -p ${tmpDir}` + + const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`) + const pluginCode = ` + export async function TestPlugin() { + return { + async ["tool.register"](_input, { registerHTTP }) { + registerHTTP({ + id: "from-plugin", + description: "Registered from test plugin", + parameters: { type: "object", properties: { name: { type: "string", optional: true } } }, + callbackUrl: "http://localhost:9999/echo" + }) + } + } + } + ` + await Bun.write(tmpPluginPath, pluginCode) + + const configPath = path.join(tmpDir, "opencode.json") + await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2)) + + await Instance.provide(tmpDir, async () => { + const { Plugin } = await import("../../src/plugin") + const { ToolRegistry } = await import("../../src/tool/registry") + const { Server } = await import("../../src/server/server") + + // Initialize plugins (will invoke our tool.register hook) + await Plugin.init() + + // Confirm the tool is registered + const allIDs = ToolRegistry.ids() + expect(allIDs).toContain("from-plugin") + + // Also verify via the HTTP surface + const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids")) + expect(idsRes.status).toBe(200) + const ids = (await idsRes.json()) as string[] + expect(ids).toContain("from-plugin") + }) + }) +}) + +test("Multiple plugins can each register tools", async () => { + const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`) + await Bun.$`mkdir -p ${tmpDir}` + + // Create two plugin files + const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`) + const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`) + const pluginA = ` + export async function PluginA() { + return { + async ["tool.register"](_input, { registerHTTP }) { + registerHTTP({ + id: "alpha-tool", + description: "Alpha tool", + parameters: { type: "object", properties: { a: { type: "string", optional: true } } }, + callbackUrl: "http://localhost:9999/echo" + }) + } + } + } + ` + const pluginB = ` + export async function PluginB() { + return { + async ["tool.register"](_input, { registerHTTP }) { + registerHTTP({ + id: "beta-tool", + description: "Beta tool", + parameters: { type: "object", properties: { b: { type: "number", optional: true } } }, + callbackUrl: "http://localhost:9999/echo" + }) + } + } + } + ` + await Bun.write(pluginAPath, pluginA) + await Bun.write(pluginBPath, pluginB) + + // Config with both plugins + await Bun.write( + path.join(tmpDir, "opencode.json"), + JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2), + ) + + await Instance.provide(tmpDir, async () => { + const { Plugin } = await import("../../src/plugin") + const { ToolRegistry } = await import("../../src/tool/registry") + const { Server } = await import("../../src/server/server") + + await Plugin.init() + + const ids = ToolRegistry.ids() + expect(ids).toContain("alpha-tool") + expect(ids).toContain("beta-tool") + + const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids")) + expect(res.status).toBe(200) + const httpIds = (await res.json()) as string[] + expect(httpIds).toContain("alpha-tool") + expect(httpIds).toContain("beta-tool") + }) +}) + +test("Plugin registers native/local tool with function execution", async () => { + const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`) + await Bun.$`mkdir -p ${tmpDir}` + + const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`) + const pluginCode = ` + export async function NativeToolPlugin({ $, Tool, z }) { + // Use z (zod) provided by the plugin system + + // Define a native tool using Tool.define from plugin input + const MyNativeTool = Tool.define("my-native-tool", { + description: "A native tool that runs local code", + parameters: z.object({ + message: z.string().describe("Message to process"), + count: z.number().optional().describe("Repeat count").default(1) + }), + async execute(args, ctx) { + // This runs locally in the plugin process, not via HTTP! + const result = args.message.repeat(args.count) + const output = \`Processed: \${result}\` + + // Can also run shell commands directly + const hostname = await $\`hostname\`.text() + + return { + title: "Native Tool Result", + output: output + " on " + hostname.trim(), + metadata: { processedAt: new Date().toISOString() } + } + } + }) + + return { + async ["tool.register"](_input, { register, registerHTTP }) { + // Register our native tool + register(MyNativeTool) + + // Can also register HTTP tools in the same plugin + registerHTTP({ + id: "http-tool-from-same-plugin", + description: "HTTP tool alongside native tool", + parameters: { type: "object", properties: {} }, + callbackUrl: "http://localhost:9999/echo" + }) + } + } + } + ` + await Bun.write(pluginPath, pluginCode) + + await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2)) + + await Instance.provide(tmpDir, async () => { + const { Plugin } = await import("../../src/plugin") + const { ToolRegistry } = await import("../../src/tool/registry") + const { Server } = await import("../../src/server/server") + + await Plugin.init() + + // Both tools should be registered + const ids = ToolRegistry.ids() + expect(ids).toContain("my-native-tool") + expect(ids).toContain("http-tool-from-same-plugin") + + // Verify via HTTP endpoint + const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids")) + expect(res.status).toBe(200) + const httpIds = (await res.json()) as string[] + expect(httpIds).toContain("my-native-tool") + expect(httpIds).toContain("http-tool-from-same-plugin") + + // Get tool details to verify native tool has proper structure + const toolsRes = await Server.App.fetch( + new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"), + ) + expect(toolsRes.status).toBe(200) + const tools = (await toolsRes.json()) as any[] + const nativeTool = tools.find((t) => t.id === "my-native-tool") + expect(nativeTool).toBeTruthy() + expect(nativeTool.description).toBe("A native tool that runs local code") + expect(nativeTool.parameters.properties.message).toBeTruthy() + expect(nativeTool.parameters.properties.count).toBeTruthy() + }) +}) + +// Malformed plugin (no tool.register) should not throw and should not register anything +test("Plugin without tool.register is handled gracefully", async () => { + const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`) + await Bun.$`mkdir -p ${tmpDir}` + + const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`) + const pluginSrc = ` + export async function NoRegisterPlugin() { + return { + // no tool.register hook provided + async config(_cfg) { /* noop */ } + } + } + ` + await Bun.write(pluginPath, pluginSrc) + + await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2)) + + await Instance.provide(tmpDir, async () => { + const { Plugin } = await import("../../src/plugin") + const { ToolRegistry } = await import("../../src/tool/registry") + const { Server } = await import("../../src/server/server") + + await Plugin.init() + + // Ensure our specific id isn't present + const ids = ToolRegistry.ids() + expect(ids).not.toContain("malformed-tool") + + const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids")) + expect(res.status).toBe(200) + const httpIds = (await res.json()) as string[] + expect(httpIds).not.toContain("malformed-tool") + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b8da5a17a..789216dee 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": "0.6.4", + "version": "0.6.6", "type": "module", "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a00b48d10..fca176f57 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -18,9 +18,34 @@ export type PluginInput = { directory: string worktree: string $: BunShell + Tool: { + define( + id: string, + init: any | (() => Promise<any>) + ): any + } + z: any // Zod instance for creating schemas } export type Plugin = (input: PluginInput) => Promise<Hooks> +// Lightweight schema spec for HTTP-registered tools +export type HttpParamSpec = { + type: "string" | "number" | "boolean" | "array" + description?: string + optional?: boolean + items?: "string" | "number" | "boolean" +} +export type HttpToolRegistration = { + id: string + description: string + parameters: { + type: "object" + properties: Record<string, HttpParamSpec> + } + callbackUrl: string + headers?: Record<string, string> +} + export interface Hooks { event?: (input: { event: Event }) => Promise<void> config?: (input: Config) => Promise<void> @@ -99,4 +124,16 @@ export interface Hooks { metadata: any }, ) => Promise<void> + /** + * Allow plugins to register additional tools with the server. + * Use registerHTTP to add a tool that calls back to your plugin/service. + * Use register to add a native/local tool with direct function execution. + */ + "tool.register"?: ( + input: {}, + output: { + registerHTTP: (tool: HttpToolRegistration) => void | Promise<void> + register: (tool: any) => void | Promise<void> // Tool.Info type from opencode + }, + ) => Promise<void> } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index af9021e14..8d42d0c75 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": "0.6.4", + "version": "0.6.6", "type": "module", "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index ac6be13d5..ea9cfe9a1 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -10,6 +10,15 @@ import type { EventSubscribeResponses, ConfigGetData, ConfigGetResponses, + ToolRegisterData, + ToolRegisterResponses, + ToolRegisterErrors, + ToolIdsData, + ToolIdsResponses, + ToolIdsErrors, + ToolListData, + ToolListResponses, + ToolListErrors, PathGetData, PathGetResponses, SessionListData, @@ -178,6 +187,42 @@ class Config extends _HeyApiClient { } } +class Tool extends _HeyApiClient { + /** + * Register a new HTTP callback tool + */ + public register<ThrowOnError extends boolean = false>(options?: Options<ToolRegisterData, ThrowOnError>) { + return (options?.client ?? this._client).post<ToolRegisterResponses, ToolRegisterErrors, ThrowOnError>({ + url: "/experimental/tool/register", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * List all tool IDs (including built-in and dynamically registered) + */ + public ids<ThrowOnError extends boolean = false>(options?: Options<ToolIdsData, ThrowOnError>) { + return (options?.client ?? this._client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({ + url: "/experimental/tool/ids", + ...options, + }) + } + + /** + * List tools with JSON schema parameters for a provider/model + */ + public list<ThrowOnError extends boolean = false>(options: Options<ToolListData, ThrowOnError>) { + return (options.client ?? this._client).get<ToolListResponses, ToolListErrors, ThrowOnError>({ + url: "/experimental/tool", + ...options, + }) + } +} + class Path extends _HeyApiClient { /** * Get the current path @@ -649,6 +694,7 @@ export class OpencodeClient extends _HeyApiClient { project = new Project({ client: this._client }) event = new Event({ client: this._client }) config = new Config({ client: this._client }) + tool = new Tool({ client: this._client }) path = new Path({ client: this._client }) session = new Session({ client: this._client }) command = new Command({ client: this._client }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8befce1ce..2c0376ef2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1053,6 +1053,44 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" +export type _Error = { + data: { + [key: string]: unknown + } +} + +export type HttpToolRegistration = { + id: string + description: string + parameters: { + type: "object" + properties: { + [key: string]: HttpParamSpec + } + } + callbackUrl: string + headers?: { + [key: string]: string + } +} + +export type HttpParamSpec = { + type: "string" | "number" | "boolean" | "array" + description?: string + optional?: boolean + items?: "string" | "number" | "boolean" +} + +export type ToolIds = Array<string> + +export type ToolList = Array<ToolListItem> + +export type ToolListItem = { + id: string + description: string + parameters?: unknown +} + export type Path = { state: string config: string @@ -1060,12 +1098,6 @@ export type Path = { directory: string } -export type _Error = { - data: { - [key: string]: unknown - } -} - export type TextPartInput = { id?: string type: "text" @@ -1276,6 +1308,89 @@ export type ConfigGetResponses = { export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type ToolRegisterData = { + body?: HttpToolRegistration + path?: never + query?: { + directory?: string + } + url: "/experimental/tool/register" +} + +export type ToolRegisterErrors = { + /** + * Bad request + */ + 400: _Error +} + +export type ToolRegisterError = ToolRegisterErrors[keyof ToolRegisterErrors] + +export type ToolRegisterResponses = { + /** + * Tool registered successfully + */ + 200: boolean +} + +export type ToolRegisterResponse = ToolRegisterResponses[keyof ToolRegisterResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: _Error +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: _Error +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + export type PathGetData = { body?: never path?: never diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index e43d297b8..907734a12 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -659,6 +659,9 @@ func (m *editorComponent) getExitKeyText() string { // shouldSummarizePastedText determines if pasted text should be summarized func (m *editorComponent) shouldSummarizePastedText(text string) bool { + if m.app.IsBashMode { + return false + } lines := strings.Split(text, "\n") lineCount := len(lines) charCount := len(text) diff --git a/packages/web/package.json b/packages/web/package.json index 8419bcb7f..c55d4ce50 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode/web", "type": "module", - "version": "0.6.4", + "version": "0.6.6", "scripts": { "dev": "astro dev", "dev:remote": "sst shell --stage=dev --target=Web astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 00b8c9337..3d161b87d 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": "0.6.4", + "version": "0.6.6", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/sst-env.d.ts b/sst-env.d.ts index 534b20a69..c1999089e 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -64,6 +64,13 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "HONEYCOMB_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "LogProcessor": { + "type": "sst.cloudflare.Worker" + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string |
