Compare commits

..
31 Commits
Author SHA1 Message Date
opencode 58fe884327 release: v0.6.10 2025-09-10 03:32:47 +00:00
Dax Raad e69d10b6c9 repair tool calls when casing is wrong 2025-09-09 23:25:27 -04:00
opencode 10aee9755c release: v0.6.9 2025-09-09 21:17:41 +00:00
Frank 63384bc214 wip: zen 2025-09-09 16:40:12 -04:00
Frank 2508e06c58 wip: zen 2025-09-09 16:32:56 -04:00
Frank 6487d0607b wip: zen 2025-09-09 16:15:35 -04:00
Frank a3513244f1 wip: zen 2025-09-09 15:47:28 -04:00
madflowandGitHub 32b47fcc1e feat: svelte lsp (#2508) 2025-09-09 13:59:58 -05:00
Aiden ClineandGitHub fde03d3c93 fix: exit code being non zero when using run cmd (#2523) 2025-09-09 12:00:55 -05:00
GitHub Action 9045f13acc ignore: update download stats 2025-09-09 2025-09-09 12:04:32 +00:00
Frank 74f0edc7a8 wip: zen 2025-09-09 05:42:15 -04:00
opencode dcabafcdce release: v0.6.8 2025-09-09 07:40:23 +00:00
Frank 02e8242c3b Remove debug logging 2025-09-09 03:35:09 -04:00
opencode 57e26bd2fe release: v0.6.7 2025-09-09 07:23:01 +00:00
Frank 0f263bfefe Hide experimental models 2025-09-09 03:16:44 -04:00
Frank 34a33dfc16 wip: zen 2025-09-09 02:44:36 -04:00
Aiden ClineandGitHub 162a789fa2 remove edit tool from plan agent (#2505) 2025-09-08 22:00:14 -05:00
Frank 198a753b62 Merge branch 'production' into dev 2025-09-08 16:37:38 -04:00
Zack JacksonandGitHub ab3c22b77a feat: add dynamic tool registration for plugins and external services (#2420) 2025-09-08 16:25:04 -04:00
opencode f0f6e9cad7 release: v0.6.6 2025-09-08 20:20:35 +00:00
Mani SundararajanandGitHub bbaae459c6 feat: make npm package install work on windows (#2419) 2025-09-08 16:14:18 -04:00
Frank eb3c820fb8 wip: zen 2025-09-08 15:57:29 -04:00
Frank 3468808fc6 wip: zen 2025-09-08 15:51:01 -04:00
Frank cd42503e2c Zen: telemetry 2025-09-08 15:46:59 -04:00
Aiden ClineandGitHub 1cea8b9e77 tweak: reenable todowrite & todoread for qwen models (#2499) 2025-09-08 13:21:16 -05:00
Douglas DennisandGitHub d8fd7b155f fix: aws bedrock add check for govcloud (#2495) 2025-09-08 11:54:06 -05:00
GitHub Action 248a644fb0 ignore: update download stats 2025-09-08 2025-09-08 12:04:58 +00:00
Aiden ClineandGitHub c8ff81bae4 fix: silent error if bad flag was passed (#2486) 2025-09-07 23:14:38 -05:00
Aiden ClineandGitHub 74469a0d3d fix: shell invocations are dropped if last interaction was revert (#2485) 2025-09-07 21:45:13 -05:00
Aiden ClineandGitHub 4d481dea7e fix: dont paste collapse if in bash mode (#2482) 2025-09-07 20:24:49 -05:00
Jay V 48328bec6e docs: fix lander 2025-09-04 17:44:32 -10:00
44 changed files with 1455 additions and 378 deletions
+2
View File
@@ -72,3 +72,5 @@
| 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) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
+1 -1
View File
@@ -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.10"
},
"dependencies": {
"@ibm/plex": "6.4.1",
+4 -4
View File
@@ -113,7 +113,7 @@ export default function Home() {
<h3 data-component="title">npm</h3>
<button data-copy data-slot="button">
<span>
npm install -g&nbsp;<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&nbsp;<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&nbsp;<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&nbsp;<strong>opencode-bin</strong>
paru -S <strong>opencode-bin</strong>
</span>
<CopyStatus />
</button>
+24 -301
View File
@@ -1,310 +1,33 @@
import { Resource } from "@opencode/cloud-resource"
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { handler } from "~/util/zen"
const MODELS = {
// "anthropic/claude-sonnet-4": {
// auth: true,
// api: "https://api.anthropic.com",
// apiKey: Resource.ANTHROPIC_API_KEY.value,
// model: "claude-sonnet-4-20250514",
// cost: {
// input: 0.0000015,
// output: 0.000006,
// reasoning: 0.0000015,
// cacheRead: 0.0000001,
// cacheWrite: 0.0000001,
// },
// headerMappings: {},
// },
"qwen/qwen3-coder": {
id: "qwen/qwen3-coder",
auth: true,
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
cost: {
input: 0.00000038,
output: 0.00000153,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
},
headerMappings: {},
},
"grok-code": {
id: "x-ai/grok-code-fast-1",
auth: false,
api: "https://api.x.ai",
apiKey: Resource.XAI_API_KEY.value,
model: "grok-code",
cost: {
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
},
headerMappings: {
"x-grok-conv-id": "x-opencode-session",
"x-grok-req-id": "x-opencode-request",
},
},
}
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
]
class AuthError extends Error {}
class CreditsError extends Error {}
class ModelError extends Error {}
export async function POST(input: APIEvent) {
try {
const url = new URL(input.request.url)
const body = await input.request.json()
const MODEL = validateModel()
const apiKey = await authenticate()
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
await checkCredits()
// Request to model provider
const res = await fetch(new URL(url.pathname.replace(/^\/zen/, "") + url.search, MODEL.api), {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
headers.set("authorization", `Bearer ${MODEL.apiKey}`)
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...body,
model: MODEL.model,
stream_options: {
include_usage: true,
},
}),
})
// Scrub response headers
const resHeaders = new Headers()
const keepHeaders = ["content-type", "cache-control"]
for (const [k, v] of res.headers.entries()) {
if (keepHeaders.includes(k.toLowerCase())) {
resHeaders.set(k, v)
}
}
// Handle non-streaming response
if (!body.stream) {
const body = await res.json()
await trackUsage(body)
return new Response(JSON.stringify(body), {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
}
// Handle streaming response
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value }) => {
if (done) {
c.close()
return
}
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
const usage = parts
.map((part) => part.trim())
.filter((part) => part.startsWith("data: "))
.map((part) => {
try {
return JSON.parse(part.slice(6))
} catch (e) {
return {}
}
})
.find((part) => part.usage)
if (usage) await trackUsage(usage)
c.enqueue(value)
return pump()
}) || Promise.resolve()
)
}
return pump()
export function POST(input: APIEvent) {
return handler(input, {
transformBody: (body: any) => ({
...body,
stream_options: {
include_usage: true,
},
})
}),
parseUsageChunk: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
return new Response(stream, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
function validateModel() {
if (!(body.model in MODELS)) {
throw new ModelError(`Model ${body.model} not supported`)
}
return MODELS[body.model as keyof typeof MODELS]
}
async function authenticate() {
let json
try {
const authHeader = input.request.headers.get("authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) throw new AuthError("Missing API key.")
const apiKey = authHeader.split(" ")[1]
const key = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.where(eq(KeyTable.key, apiKey))
.then((rows) => rows[0]),
)
if (!key) throw new AuthError("Invalid API key.")
return key
json = JSON.parse(chunk.slice(6))
} catch (e) {
console.log(e)
// ignore error if model does not require authentication
if (!MODEL.auth) return
throw e
return
}
}
async function checkCredits() {
if (!apiKey || !MODEL.auth || isFree) return
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
.then((rows) => rows[0]),
)
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
}
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
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0
//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)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: apiKey.workspaceID,
id: Identifier.create("usage"),
model: MODEL.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, apiKey.id)),
)
}
} 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,
})
}
return json.usage
},
buildUsage: (usage: any) => ({
inputTokens: usage.prompt_tokens ?? 0,
outputTokens: usage.completion_tokens ?? 0,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? 0,
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
//cacheWriteTokens = usage.providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0
cacheWriteTokens: 0,
}),
})
}
+34
View File
@@ -0,0 +1,34 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/util/zen"
export function POST(input: APIEvent) {
return handler(input, {
parseUsageChunk: (chunk: string) => {
const [event, data] = chunk.split("\n")
if (event !== "event: response.completed") return
if (!data.startsWith("data: ")) return
let json
try {
json = JSON.parse(data.slice(6))
} catch (e) {
return
}
return json.response?.usage
},
buildUsage: (usage: any) => {
const inputTokens = usage.input_tokens ?? 0
const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0
return {
inputTokens: inputTokens - cacheReadTokens,
outputTokens: outputTokens - reasoningTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens: 0,
}
},
})
}
+367
View File
@@ -0,0 +1,367 @@
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { Resource } from "@opencode/cloud-resource"
export async function handler(
input: APIEvent,
opts: {
transformBody?: (body: any) => any
parseUsageChunk: (chunk: string) => string | undefined
buildUsage: (body: any) => {
inputTokens: number
outputTokens: number
reasoningTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
},
) {
class AuthError extends Error {}
class CreditsError extends Error {}
class ModelError extends Error {}
const MODELS = {
// "anthropic/claude-sonnet-4": {
// auth: true,
// api: "https://api.anthropic.com",
// apiKey: Resource.ANTHROPIC_API_KEY.value,
// model: "claude-sonnet-4-20250514",
// cost: {
// input: 0.0000015,
// output: 0.000006,
// reasoning: 0.0000015,
// cacheRead: 0.0000001,
// cacheWrite: 0.0000001,
// },
// headerMappings: {},
// },
"gpt-5": {
id: "gpt-5" as const,
auth: true,
api: "https://api.openai.com",
apiKey: Resource.OPENAI_API_KEY.value,
model: "gpt-5",
cost: {
input: 0.00000125,
output: 0.00001,
reasoning: 0.00001,
cacheRead: 0.000000125,
cacheWrite: 0,
},
headerMappings: {},
},
"qwen3-coder": {
id: "qwen3-coder" as const,
auth: true,
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
cost: {
input: 0.00000038,
output: 0.00000153,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
},
headerMappings: {},
},
"kimi-k2": {
id: "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: "grok-code" as const,
auth: false,
api: "https://api.x.ai",
apiKey: Resource.XAI_API_KEY.value,
model: "grok-code",
cost: {
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
},
headerMappings: {
"x-grok-conv-id": "x-opencode-session",
"x-grok-req-id": "x-opencode-request",
},
},
// deprecated
"qwen/qwen3-coder": {
id: "qwen/qwen3-coder" as const,
auth: true,
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
cost: {
input: 0.00000038,
output: 0.00000153,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
},
headerMappings: {},
},
}
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
]
const logger = {
metric: (values: Record<string, any>) => {
console.log(`_metric:${JSON.stringify(values)}`)
},
log: console.log,
debug: (message: string) => {
if (Resource.App.stage === "production") return
console.debug(message)
},
}
try {
const url = new URL(input.request.url)
const body = await input.request.json()
logger.debug(JSON.stringify(body))
logger.metric({
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 ?? "")
await checkCredits()
// Request to model provider
const res = await fetch(new URL(url.pathname.replace(/^\/zen/, "") + url.search, MODEL.api), {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
headers.set("authorization", `Bearer ${MODEL.apiKey}`)
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...(opts.transformBody?.(body) ?? body),
model: MODEL.model,
}),
})
// Scrub response headers
const resHeaders = new Headers()
const keepHeaders = ["content-type", "cache-control"]
for (const [k, v] of res.headers.entries()) {
if (keepHeaders.includes(k.toLowerCase())) {
resHeaders.set(k, v)
}
}
// Handle non-streaming response
if (!body.stream) {
const json = await res.json()
const body = JSON.stringify(json)
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(json.usage)
return new Response(body, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
}
// Handle streaming response
const stream = new ReadableStream({
start(c) {
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) {
logger.metric({ response_length: responseLength })
c.close()
return
}
if (!receivedFirstByte) {
receivedFirstByte = true
logger.metric({ 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() ?? ""
for (const part of parts) {
logger.debug(part)
const usage = opts.parseUsageChunk(part.trim())
if (usage) await trackUsage(usage)
}
c.enqueue(value)
return pump()
}) || Promise.resolve()
)
}
return pump()
},
})
return new Response(stream, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
function validateModel() {
if (!(body.model in MODELS)) {
throw new ModelError(`Model ${body.model} not supported`)
}
const model = MODELS[body.model as keyof typeof MODELS]
logger.metric({ model: model.id })
return model
}
async function authenticate() {
try {
const authHeader = input.request.headers.get("authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) throw new AuthError("Missing API key.")
const apiKey = authHeader.split(" ")[1]
const key = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.where(eq(KeyTable.key, apiKey))
.then((rows) => rows[0]),
)
if (!key) throw new AuthError("Invalid API key.")
logger.metric({
api_key: key.id,
workspace: key.workspaceID,
})
return key
} catch (e) {
// ignore error if model does not require authentication
if (!MODEL.auth) return
throw e
}
}
async function checkCredits() {
if (!apiKey || !MODEL.auth || isFree) return
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
.then((rows) => rows[0]),
)
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
}
async function trackUsage(usage: any) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWriteTokens } = opts.buildUsage(usage)
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
logger.metric({
"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,
id: Identifier.create("usage"),
model: MODEL.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, apiKey.id)),
)
}
} catch (error: any) {
logger.metric({
"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 })
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.6.5",
"version": "0.6.10",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.6.5",
"version": "0.6.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+49
View File
@@ -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())
}
},
}
+9
View File
@@ -50,6 +50,14 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +84,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
}
}
+3
View File
@@ -8,6 +8,9 @@ export const Resource = new Proxy(
// @ts-expect-error
const value = env[prop]
return typeof value === "string" ? JSON.parse(value) : value
} else if (prop === "App") {
// @ts-expect-error
return JSON.parse(env.SST_RESOURCE_App)
}
throw new Error(`"${prop}" is not linked in your sst.config.ts (cloudflare)`)
},
+9
View File
@@ -50,6 +50,14 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +84,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-scripts",
"version": "0.6.5",
"version": "0.6.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+14 -4
View File
@@ -7,7 +7,7 @@ import { domain } from "./stage"
const cluster = planetscale.getDatabaseOutput({
name: "opencode",
organization: "sst",
organization: "anomalyco",
})
const branch =
@@ -100,6 +100,7 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
})
const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY")
const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY")
const XAI_API_KEY = new sst.Secret("XAI_API_KEY")
const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
@@ -114,6 +115,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",
@@ -123,6 +133,7 @@ new sst.cloudflare.x.SolidStart("Console", {
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ANTHROPIC_API_KEY,
OPENAI_API_KEY,
XAI_API_KEY,
BASETEN_API_KEY,
],
@@ -135,9 +146,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
transform: {
worker: {
placement: {
mode: "smart",
},
placement: { mode: "smart" },
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
},
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.6.5",
"version": "0.6.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+9
View File
@@ -50,6 +50,14 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +84,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
}
}
+4 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.6.5",
"version": "0.6.10",
"name": "opencode",
"type": "module",
"private": true,
+5
View File
@@ -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")
+32
View File
@@ -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)
}
+2
View File
@@ -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,
+1 -1
View File
@@ -50,7 +50,7 @@ export namespace Agent {
const planPermission = mergeAgentPermissions(
{
edit: "ask",
edit: "deny",
bash: "ask",
webfetch: "allow",
},
+4 -3
View File
@@ -71,7 +71,7 @@ export const RunCommand = cmd({
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
return
process.exit(1)
}
await bootstrap(process.cwd(), async () => {
@@ -79,7 +79,7 @@ export const RunCommand = cmd({
const exists = await Command.get(args.command)
if (!exists) {
UI.error(`Command "${args.command}" not found`)
return
process.exit(1)
}
}
const session = await (async () => {
@@ -104,7 +104,7 @@ export const RunCommand = cmd({
if (!session) {
UI.error("Session not found")
return
process.exit(1)
}
const cfg = await Config.get()
@@ -221,6 +221,7 @@ export const RunCommand = cmd({
if (errorMsg) process.stdout.write(errorMsg)
}
UI.empty()
if (errorMsg) process.exit(1)
})
},
})
+1
View File
@@ -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()
+5 -1
View File
@@ -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)
+1
View File
@@ -81,6 +81,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".svelte": "svelte",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
+52
View File
@@ -654,4 +654,56 @@ export namespace LSPServer {
}
},
}
export const Svelte: Info = {
id: "svelte",
extensions: [".svelte"],
root: NearestRoot([
"tsconfig.json",
"jsconfig.json",
"package.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"bun.lock",
"vite.config.ts",
"vite.config.js",
"svelte.config.ts",
"svelte.config.js",
]),
async spawn(root) {
let binary = Bun.which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
initialization: {},
}
},
}
}
+8
View File
@@ -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)
+2
View File
@@ -28,7 +28,9 @@ export namespace ModelsDev {
context: z.number(),
output: z.number(),
}),
experimental: z.boolean().optional(),
options: z.record(z.any()),
provider: z.object({ npm: z.string() }).optional(),
})
.openapi({
ref: "Model",
+15 -9
View File
@@ -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
@@ -233,6 +235,7 @@ export namespace Provider {
context: 0,
output: 0,
},
provider: model.provider ?? existing?.provider,
}
parsed.models[modelID] = parsedModel
}
@@ -287,12 +290,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
@@ -314,7 +320,7 @@ export namespace Provider {
return state().then((state) => state.providers)
}
async function getSDK(provider: ModelsDev.Provider) {
async function getSDK(provider: ModelsDev.Provider, model: ModelsDev.Model) {
return (async () => {
using _ = log.time("getSDK", {
providerID: provider.id,
@@ -322,7 +328,7 @@ export namespace Provider {
const s = await state()
const existing = s.sdk.get(provider.id)
if (existing) return existing
const pkg = provider.npm ?? provider.id
const pkg = model.provider?.npm ?? provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
let options = { ...s.providers[provider.id]?.options }
@@ -361,7 +367,7 @@ export namespace Provider {
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID })
const sdk = await getSDK(provider.info)
const sdk = await getSDK(provider.info, info)
try {
const language = provider.getModel ? await provider.getModel(sdk, modelID) : sdk.languageModel(modelID)
+118
View File
@@ -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({
+46 -25
View File
@@ -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"),
@@ -1001,6 +1007,17 @@ export namespace Session {
}
},
async experimental_repairToolCall(input) {
const lower = input.toolCall.toolName.toLowerCase()
if (lower !== input.toolCall.toolName && tools[lower]) {
log.info("repairing tool call", {
tool: input.toolCall.toolName,
repaired: lower,
})
return {
...input.toolCall,
toolName: lower,
}
}
return {
...input.toolCall,
input: JSON.stringify({
@@ -1085,6 +1102,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,
+99 -12
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.6.5",
"version": "0.6.10",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+37
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.6.5",
"version": "0.6.10",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+46
View File
@@ -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 })
+129 -6
View File
@@ -637,9 +637,13 @@ export type Config = {
context: number
output: number
}
experimental?: boolean
options?: {
[key: string]: unknown
}
provider?: {
npm: string
}
}
}
options?: {
@@ -1004,9 +1008,13 @@ export type Model = {
context: number
output: number
}
experimental?: boolean
options: {
[key: string]: unknown
}
provider?: {
npm: string
}
}
export type McpLocalConfig = {
@@ -1053,6 +1061,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 +1106,6 @@ export type Path = {
directory: string
}
export type _Error = {
data: {
[key: string]: unknown
}
}
export type TextPartInput = {
id?: string
type: "text"
@@ -1276,6 +1316,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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.6.5",
"version": "0.6.10",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
+1
View File
@@ -24,6 +24,7 @@ opencode comes with several built-in LSP servers for popular languages:
| vue | .vue | Auto-installs for Vue projects |
| rust | .rs | `rust-analyzer` command available |
| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | Auto-installs for C/C++ projects |
| svelte | .svelte | Auto-installs for Svelte projects |
LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.6.5",
"version": "0.6.10",
"publisher": "sst-dev",
"repository": {
"type": "git",
+11
View File
@@ -64,6 +64,17 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"LogProcessor": {
"type": "sst.cloudflare.Worker"
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string