mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcabafcdce | |||
| 02e8242c3b | |||
| 57e26bd2fe | |||
| 0f263bfefe | |||
| 34a33dfc16 | |||
| 162a789fa2 | |||
| 198a753b62 | |||
| ab3c22b77a | |||
| f0f6e9cad7 | |||
| bbaae459c6 | |||
| eb3c820fb8 | |||
| 3468808fc6 | |||
| cd42503e2c | |||
| 1cea8b9e77 | |||
| d8fd7b155f | |||
| 248a644fb0 | |||
| c8ff81bae4 | |||
| 74469a0d3d | |||
| 4d481dea7e | |||
| 48328bec6e |
@@ -72,3 +72,4 @@
|
||||
| 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) |
|
||||
|
||||
@@ -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.5"
|
||||
"version": "0.6.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function Home() {
|
||||
<h3 data-component="title">npm</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
npm install -g <strong>opencode-ai</strong>
|
||||
npm install -g <strong>opencode-ai</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
@@ -122,7 +122,7 @@ export default function Home() {
|
||||
<h3 data-component="title">bun</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
bun install -g <strong>opencode-ai</strong>
|
||||
bun install -g <strong>opencode-ai</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
@@ -131,7 +131,7 @@ export default function Home() {
|
||||
<h3 data-component="title">homebrew</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
brew install <strong>sst/tap/opencode</strong>
|
||||
brew install <strong>sst/tap/opencode</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
@@ -140,7 +140,7 @@ export default function Home() {
|
||||
<h3 data-component="title">paru</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
paru -S <strong>opencode-bin</strong>
|
||||
paru -S <strong>opencode-bin</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
|
||||
@@ -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,
|
||||
@@ -36,8 +36,23 @@ const MODELS = {
|
||||
},
|
||||
headerMappings: {},
|
||||
},
|
||||
"moonshotai/kimi-k2": {
|
||||
id: "moonshotai/kimi-k2" as const,
|
||||
auth: true,
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "moonshotai/Kimi-K2-Instruct-0905",
|
||||
cost: {
|
||||
input: 0.0000006,
|
||||
output: 0.0000025,
|
||||
reasoning: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
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 +83,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 +126,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 +143,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 +201,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 +224,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 +253,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 +261,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 +313,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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 ModelError) {
|
||||
return new Response(JSON.stringify({ error: { message: error.message } }), {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(error)
|
||||
return new Response(JSON.stringify({ error: { message: error.message } }), {
|
||||
status: 500,
|
||||
logMetric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
})
|
||||
|
||||
if (error instanceof AuthError || error instanceof CreditsError || 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 })
|
||||
}
|
||||
|
||||
function logMetric(values: Record<string, any>) {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cloud-core",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-function",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
},
|
||||
}
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-scripts",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+12
-4
@@ -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 }] : [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -50,7 +50,7 @@ export namespace Agent {
|
||||
|
||||
const planPermission = mergeAgentPermissions(
|
||||
{
|
||||
edit: "ask",
|
||||
edit: "deny",
|
||||
bash: "ask",
|
||||
webfetch: "allow",
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export namespace Flag {
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,7 @@ export namespace ModelsDev {
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
experimental: z.boolean().optional(),
|
||||
options: z.record(z.any()),
|
||||
})
|
||||
.openapi({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Global } from "../global"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
@@ -81,7 +82,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
|
||||
@@ -287,12 +289,15 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
for (const [providerID, provider] of Object.entries(providers)) {
|
||||
// Filter out blacklisted models
|
||||
const filteredModels = Object.fromEntries(
|
||||
Object.entries(provider.info.models).filter(
|
||||
([modelID]) =>
|
||||
modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
|
||||
),
|
||||
Object.entries(provider.info.models)
|
||||
// Filter out blacklisted models
|
||||
.filter(
|
||||
([modelID]) =>
|
||||
modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
|
||||
)
|
||||
// Filter out experimental models
|
||||
.filter(([, model]) => !model.experimental || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS),
|
||||
)
|
||||
provider.info.models = filteredModels
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
@@ -1085,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -637,6 +637,7 @@ export type Config = {
|
||||
context: number
|
||||
output: number
|
||||
}
|
||||
experimental?: boolean
|
||||
options?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -1004,6 +1005,7 @@ export type Model = {
|
||||
context: number
|
||||
output: number
|
||||
}
|
||||
experimental?: boolean
|
||||
options: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -1053,6 +1055,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 +1100,6 @@ export type Path = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
export type _Error = {
|
||||
data: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type TextPartInput = {
|
||||
id?: string
|
||||
type: "text"
|
||||
@@ -1276,6 +1310,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.8",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user