mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-27 03:04:37 +00:00
Compare commits
11 Commits
production
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9398303767 | ||
|
|
7a8a32fc3c | ||
|
|
8b4eb642e2 | ||
|
|
9b9268d3b9 | ||
|
|
17259c0c24 | ||
|
|
14aaa7a626 | ||
|
|
9c578006c7 | ||
|
|
7b4180d153 | ||
|
|
3a3a4cdfe6 | ||
|
|
5745ee87ba | ||
|
|
08f056d412 |
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||
@@ -7,7 +8,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
@@ -60,6 +61,7 @@ const ProjectTile = (props: {
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
overlay: Accessor<boolean>
|
||||
suppressHover: Accessor<boolean>
|
||||
dirs: Accessor<string[]>
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
@@ -71,9 +73,11 @@ const ProjectTile = (props: {
|
||||
closeProject: (directory: string) => void
|
||||
setMenu: (value: boolean) => void
|
||||
setOpen: (value: boolean) => void
|
||||
setSuppressHover: (value: boolean) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const layout = useLayout()
|
||||
const unseenCount = createMemo(() =>
|
||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
@@ -107,17 +111,28 @@ const ProjectTile = (props: {
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectMouseEnter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (props.suppressHover()) props.setSuppressHover(false)
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseLeave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => props.navigateToProject(props.project.worktree)}
|
||||
onClick={() => {
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
@@ -278,16 +293,19 @@ export const SortableProject = (props: {
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [menu, setMenu] = createSignal(false)
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(() =>
|
||||
projectTileActive({
|
||||
menu: menu(),
|
||||
menu: state.menu,
|
||||
preview: preview(),
|
||||
open: open(),
|
||||
open: state.open,
|
||||
overlay: overlay(),
|
||||
hoverProject: props.ctx.hoverProject(),
|
||||
worktree: props.project.worktree,
|
||||
@@ -296,8 +314,14 @@ export const SortableProject = (props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!open()) return
|
||||
setOpen(false)
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
const label = (directory: string) => {
|
||||
@@ -328,6 +352,7 @@ export const SortableProject = (props: {
|
||||
selected={selected}
|
||||
active={active}
|
||||
overlay={overlay}
|
||||
suppressHover={() => state.suppressHover}
|
||||
dirs={dirs}
|
||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||
@@ -337,8 +362,9 @@ export const SortableProject = (props: {
|
||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={setMenu}
|
||||
setOpen={setOpen}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
)
|
||||
@@ -346,17 +372,18 @@ export const SortableProject = (props: {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview()} fallback={tile()}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={open() && !menu()}
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
trigger={tile()}
|
||||
onOpenChange={(value) => {
|
||||
if (menu()) return
|
||||
setOpen(value)
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -371,7 +398,7 @@ export const SortableProject = (props: {
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={setOpen}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE `workspace` (
|
||||
`id` text PRIMARY KEY,
|
||||
`branch` text,
|
||||
`project_id` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
1009
packages/opencode/migration/20260225215848_workspace/snapshot.json
Normal file
1009
packages/opencode/migration/20260225215848_workspace/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,9 @@ import { Server } from "../../server/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Workspace } from "../../control-plane/workspace"
|
||||
import { Project } from "../../project/project"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
@@ -14,7 +17,15 @@ export const ServeCommand = cmd({
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
let workspaceSync: Array<ReturnType<typeof Workspace.startSyncing>> = []
|
||||
// Only available in development right now
|
||||
if (Installation.isLocal()) {
|
||||
workspaceSync = Project.list().map((project) => Workspace.startSyncing(project))
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
await Promise.all(workspaceSync.map((item) => item.stop()))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,59 +1,16 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Installation } from "../../installation"
|
||||
import { WorkspaceServer } from "../../control-plane/workspace-server/server"
|
||||
|
||||
export const WorkspaceServeCommand = cmd({
|
||||
command: "workspace-serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a remote workspace websocket server",
|
||||
describe: "starts a remote workspace event server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Bun.serve<{ id: string }>({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === "/ws") {
|
||||
const id = Bun.randomUUIDv7()
|
||||
if (server.upgrade(req, { data: { id } })) return
|
||||
return new Response("Upgrade failed", { status: 400 })
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
service: "workspace-server",
|
||||
ws: `ws://${server.hostname}:${server.port}/ws`,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
ws.send(JSON.stringify({ type: "ready", id: ws.data.id }))
|
||||
},
|
||||
message(ws, msg) {
|
||||
const text = typeof msg === "string" ? msg : msg.toString()
|
||||
ws.send(JSON.stringify({ type: "message", id: ws.data.id, text }))
|
||||
},
|
||||
close() {},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`)
|
||||
const server = WorkspaceServer.Listen(opts)
|
||||
console.log(`workspace event server listening on http://${server.hostname}:${server.port}/event`)
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
10
packages/opencode/src/control-plane/adaptors/index.ts
Normal file
10
packages/opencode/src/control-plane/adaptors/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { WorktreeAdaptor } from "./worktree"
|
||||
import type { Config } from "../config"
|
||||
import type { Adaptor } from "./types"
|
||||
|
||||
export function getAdaptor(config: Config): Adaptor {
|
||||
switch (config.type) {
|
||||
case "worktree":
|
||||
return WorktreeAdaptor
|
||||
}
|
||||
}
|
||||
7
packages/opencode/src/control-plane/adaptors/types.ts
Normal file
7
packages/opencode/src/control-plane/adaptors/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "../config"
|
||||
|
||||
export type Adaptor<T extends Config = Config> = {
|
||||
create(from: T, branch?: string | null): Promise<{ config: T; init: () => Promise<void> }>
|
||||
remove(from: T): Promise<void>
|
||||
request(from: T, method: string, url: string, data?: BodyInit, signal?: AbortSignal): Promise<Response | undefined>
|
||||
}
|
||||
26
packages/opencode/src/control-plane/adaptors/worktree.ts
Normal file
26
packages/opencode/src/control-plane/adaptors/worktree.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Worktree } from "@/worktree"
|
||||
import type { Config } from "../config"
|
||||
import type { Adaptor } from "./types"
|
||||
|
||||
type WorktreeConfig = Extract<Config, { type: "worktree" }>
|
||||
|
||||
export const WorktreeAdaptor: Adaptor<WorktreeConfig> = {
|
||||
async create(_from: WorktreeConfig, _branch: string) {
|
||||
const next = await Worktree.create(undefined)
|
||||
return {
|
||||
config: {
|
||||
type: "worktree",
|
||||
directory: next.directory,
|
||||
},
|
||||
// Hack for now: `Worktree.create` puts all its async code in a
|
||||
// `setTimeout` so it doesn't use this, but we should change that
|
||||
init: async () => {},
|
||||
}
|
||||
},
|
||||
async remove(config: WorktreeConfig) {
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
async request(_from: WorktreeConfig, _method: string, _url: string, _data?: BodyInit, _signal?: AbortSignal) {
|
||||
throw new Error("worktree does not support request")
|
||||
},
|
||||
}
|
||||
10
packages/opencode/src/control-plane/config.ts
Normal file
10
packages/opencode/src/control-plane/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import z from "zod"
|
||||
|
||||
export const Config = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
directory: z.string(),
|
||||
type: z.literal("worktree"),
|
||||
}),
|
||||
])
|
||||
|
||||
export type Config = z.infer<typeof Config>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Installation } from "../installation"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { Workspace } from "./workspace"
|
||||
|
||||
// This middleware forwards all non-GET requests if the workspace is a
|
||||
// remote. The remote workspace needs to handle session mutations
|
||||
async function proxySessionRequest(req: Request) {
|
||||
if (req.method === "GET") return
|
||||
if (!Instance.directory.startsWith("wrk_")) return
|
||||
|
||||
const workspace = await Workspace.get(Instance.directory)
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${Instance.directory}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (workspace.config.type === "worktree") return
|
||||
|
||||
const url = new URL(req.url)
|
||||
const body = req.method === "HEAD" ? undefined : await req.arrayBuffer()
|
||||
return getAdaptor(workspace.config).request(
|
||||
workspace.config,
|
||||
req.method,
|
||||
`${url.pathname}${url.search}`,
|
||||
body,
|
||||
req.signal,
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionProxyMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// Only available in development for now
|
||||
if (!Installation.isLocal()) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const response = await proxySessionRequest(c.req.raw)
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
return next()
|
||||
}
|
||||
66
packages/opencode/src/control-plane/sse.ts
Normal file
66
packages/opencode/src/control-plane/sse.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export async function parseSSE(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: unknown) => void,
|
||||
) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ""
|
||||
let last = ""
|
||||
let retry = 1000
|
||||
|
||||
const abort = () => {
|
||||
void reader.cancel().catch(() => undefined)
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", abort)
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
|
||||
if (chunk.done) break
|
||||
|
||||
buf += decoder.decode(chunk.value, { stream: true })
|
||||
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
|
||||
const chunks = buf.split("\n\n")
|
||||
buf = chunks.pop() ?? ""
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const data: string[] = []
|
||||
chunk.split("\n").forEach((line) => {
|
||||
if (line.startsWith("data:")) {
|
||||
data.push(line.replace(/^data:\s*/, ""))
|
||||
return
|
||||
}
|
||||
if (line.startsWith("id:")) {
|
||||
last = line.replace(/^id:\s*/, "")
|
||||
return
|
||||
}
|
||||
if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
|
||||
if (!Number.isNaN(parsed)) retry = parsed
|
||||
}
|
||||
})
|
||||
|
||||
if (!data.length) return
|
||||
const raw = data.join("\n")
|
||||
try {
|
||||
onEvent(JSON.parse(raw))
|
||||
} catch {
|
||||
onEvent({
|
||||
type: "sse.message",
|
||||
properties: {
|
||||
data: raw,
|
||||
id: last || undefined,
|
||||
retry,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { GlobalBus } from "../../bus/global"
|
||||
import { Hono } from "hono"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
|
||||
export function WorkspaceServerRoutes() {
|
||||
return new Hono().get("/event", async (c) => {
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const send = async (event: unknown) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
}
|
||||
const handler = async (event: { directory?: string; payload: unknown }) => {
|
||||
await send(event.payload)
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
await send({ type: "server.connected", properties: {} })
|
||||
const heartbeat = setInterval(() => {
|
||||
void send({ type: "server.heartbeat", properties: {} })
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
clearInterval(heartbeat)
|
||||
GlobalBus.off("event", handler)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Hono } from "hono"
|
||||
import { SessionRoutes } from "../../server/routes/session"
|
||||
import { WorkspaceServerRoutes } from "./routes"
|
||||
|
||||
export namespace WorkspaceServer {
|
||||
export function App() {
|
||||
const session = new Hono()
|
||||
.use("*", async (c, next) => {
|
||||
if (c.req.method === "GET") return c.notFound()
|
||||
await next()
|
||||
})
|
||||
.route("/", SessionRoutes())
|
||||
|
||||
return new Hono().route("/session", session).route("/", WorkspaceServerRoutes())
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
fetch: App().fetch,
|
||||
})
|
||||
}
|
||||
}
|
||||
12
packages/opencode/src/control-plane/workspace.sql.ts
Normal file
12
packages/opencode/src/control-plane/workspace.sql.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import type { Config } from "./config"
|
||||
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().primaryKey(),
|
||||
branch: text(),
|
||||
project_id: text()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
config: text({ mode: "json" }).notNull().$type<Config>(),
|
||||
})
|
||||
160
packages/opencode/src/control-plane/workspace.ts
Normal file
160
packages/opencode/src/control-plane/workspace.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import z from "zod"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Log } from "@/util/log"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { Config } from "./config"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { parseSSE } from "./sse"
|
||||
|
||||
export namespace Workspace {
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
Failed: BusEvent.define(
|
||||
"workspace.failed",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("workspace"),
|
||||
branch: z.string().nullable(),
|
||||
projectID: z.string(),
|
||||
config: Config,
|
||||
})
|
||||
.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
return {
|
||||
id: row.id,
|
||||
branch: row.branch,
|
||||
projectID: row.project_id,
|
||||
config: row.config,
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
id: Identifier.schema("workspace").optional(),
|
||||
projectID: Info.shape.projectID,
|
||||
branch: Info.shape.branch,
|
||||
config: Info.shape.config,
|
||||
}),
|
||||
async (input) => {
|
||||
const id = Identifier.ascending("workspace", input.id)
|
||||
|
||||
const { config, init } = await getAdaptor(input.config).create(input.config, input.branch)
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
projectID: input.projectID,
|
||||
branch: input.branch,
|
||||
config,
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await init()
|
||||
|
||||
Database.use((db) => {
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
branch: info.branch,
|
||||
project_id: info.projectID,
|
||||
config: info.config,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: id,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return info
|
||||
},
|
||||
)
|
||||
|
||||
export function list(project: Project.Info) {
|
||||
const rows = Database.use((db) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
|
||||
)
|
||||
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const get = fn(Identifier.schema("workspace"), async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (!row) return
|
||||
return fromRow(row)
|
||||
})
|
||||
|
||||
export const remove = fn(Identifier.schema("workspace"), async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (row) {
|
||||
const info = fromRow(row)
|
||||
await getAdaptor(info.config).remove(info.config)
|
||||
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||
return info
|
||||
}
|
||||
})
|
||||
const log = Log.create({ service: "workspace-sync" })
|
||||
|
||||
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
|
||||
while (!stop.aborted) {
|
||||
const res = await getAdaptor(space.config)
|
||||
.request(space.config, "GET", "/event", undefined, stop)
|
||||
.catch(() => undefined)
|
||||
if (!res || !res.ok || !res.body) {
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await Bun.sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
export function startSyncing(project: Project.Info) {
|
||||
const stop = new AbortController()
|
||||
const spaces = list(project).filter((space) => space.config.type !== "worktree")
|
||||
|
||||
spaces.forEach((space) => {
|
||||
void workspaceEventLoop(space, stop.signal).catch((error) => {
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
stop.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export namespace Identifier {
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Session } from "../../session"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -112,6 +113,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
return c.json(worktree)
|
||||
},
|
||||
)
|
||||
.route("/workspace", WorkspaceRoutes())
|
||||
.get(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
|
||||
@@ -16,11 +16,13 @@ import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { SessionProxyMiddleware } from "../../control-plane/session-proxy-middleware"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const SessionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.use(SessionProxyMiddleware)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
|
||||
104
packages/opencode/src/server/routes/workspace.ts
Normal file
104
packages/opencode/src/server/routes/workspace.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Workspace } from "../../control-plane/workspace"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
operationId: "experimental.workspace.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Workspace.Info.shape.id,
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
branch: Workspace.Info.shape.branch,
|
||||
config: Workspace.Info.shape.config,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
const body = c.req.valid("json")
|
||||
const workspace = await Workspace.create({
|
||||
id,
|
||||
projectID: Instance.project.id,
|
||||
branch: body.branch,
|
||||
config: body.config,
|
||||
})
|
||||
return c.json(workspace)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
operationId: "experimental.workspace.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspaces",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.Info)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Workspace.list(Instance.project))
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
operationId: "experimental.workspace.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Workspace.Info.shape.id,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
return c.json(await Workspace.remove(id))
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -2,3 +2,4 @@ export { ControlAccountTable } from "../control/control.sql"
|
||||
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
export { SessionShareTable } from "../share/share.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Hono } from "hono"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
type State = {
|
||||
workspace?: "first" | "second"
|
||||
calls: Array<{ method: string; url: string; body?: string }>
|
||||
}
|
||||
|
||||
const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config
|
||||
|
||||
async function setup(state: State) {
|
||||
mock.module("../../src/control-plane/adaptors", () => ({
|
||||
getAdaptor: () => ({
|
||||
request: async (_config: unknown, method: string, url: string, data?: BodyInit) => {
|
||||
const body = data ? await new Response(data).text() : undefined
|
||||
state.calls.push({ method, url, body })
|
||||
return new Response("proxied", { status: 202 })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
const id1 = Identifier.descending("workspace")
|
||||
const id2 = Identifier.descending("workspace")
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(WorkspaceTable)
|
||||
.values([
|
||||
{
|
||||
id: id1,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
config: remote,
|
||||
},
|
||||
{
|
||||
id: id2,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
config: { type: "worktree", directory: tmp.path },
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
|
||||
const { SessionProxyMiddleware } = await import("../../src/control-plane/session-proxy-middleware")
|
||||
const app = new Hono().use(SessionProxyMiddleware)
|
||||
|
||||
return {
|
||||
id1,
|
||||
id2,
|
||||
app,
|
||||
async request(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return Instance.provide({
|
||||
directory: state.workspace === "first" ? id1 : id2,
|
||||
fn: async () => app.request(input, init),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("control-plane/session-proxy-middleware", () => {
|
||||
test("forwards non-GET session requests for remote workspaces", async () => {
|
||||
const state: State = {
|
||||
workspace: "first",
|
||||
calls: [],
|
||||
}
|
||||
|
||||
const ctx = await setup(state)
|
||||
|
||||
ctx.app.post("/session/foo", (c) => c.text("local", 200))
|
||||
const response = await ctx.request("http://workspace.test/session/foo?x=1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(await response.text()).toBe("proxied")
|
||||
expect(state.calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
url: "/session/foo?x=1",
|
||||
body: '{"hello":"world"}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not forward GET requests", async () => {
|
||||
const state: State = {
|
||||
workspace: "first",
|
||||
calls: [],
|
||||
}
|
||||
|
||||
const ctx = await setup(state)
|
||||
|
||||
ctx.app.get("/session/foo", (c) => c.text("local", 200))
|
||||
const response = await ctx.request("http://workspace.test/session/foo?x=1")
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe("local")
|
||||
expect(state.calls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not forward GET or POST requests for worktree workspaces", async () => {
|
||||
const state: State = {
|
||||
workspace: "second",
|
||||
calls: [],
|
||||
}
|
||||
|
||||
const ctx = await setup(state)
|
||||
|
||||
ctx.app.get("/session/foo", (c) => c.text("local-get", 200))
|
||||
ctx.app.post("/session/foo", (c) => c.text("local-post", 200))
|
||||
|
||||
const getResponse = await ctx.request("http://workspace.test/session/foo?x=1")
|
||||
const postResponse = await ctx.request("http://workspace.test/session/foo?x=1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(getResponse.status).toBe(200)
|
||||
expect(await getResponse.text()).toBe("local-get")
|
||||
expect(postResponse.status).toBe(200)
|
||||
expect(await postResponse.text()).toBe("local-post")
|
||||
expect(state.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
56
packages/opencode/test/control-plane/sse.test.ts
Normal file
56
packages/opencode/test/control-plane/sse.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { parseSSE } from "../../src/control-plane/sse"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function stream(chunks: string[]) {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("control-plane/sse", () => {
|
||||
test("parses JSON events with CRLF and multiline data blocks", async () => {
|
||||
const events: unknown[] = []
|
||||
const stop = new AbortController()
|
||||
|
||||
await parseSSE(
|
||||
stream([
|
||||
'data: {"type":"one","properties":{"ok":true}}\r\n\r\n',
|
||||
'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n',
|
||||
]),
|
||||
stop.signal,
|
||||
(event) => events.push(event),
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "one", properties: { ok: true } },
|
||||
{ type: "two", properties: { n: 2 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("falls back to sse.message for non-json payload", async () => {
|
||||
const events: unknown[] = []
|
||||
const stop = new AbortController()
|
||||
|
||||
await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event))
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "sse.message",
|
||||
properties: {
|
||||
data: "hello world",
|
||||
id: "abc",
|
||||
retry: 1500,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { WorkspaceServer } from "../../src/control-plane/workspace-server/server"
|
||||
import { parseSSE } from "../../src/control-plane/sse"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("control-plane/workspace-server SSE", () => {
|
||||
test("streams GlobalBus events and parseSSE reads them", async () => {
|
||||
const app = WorkspaceServer.App()
|
||||
const stop = new AbortController()
|
||||
const seen: unknown[] = []
|
||||
|
||||
try {
|
||||
const response = await app.request("/event", {
|
||||
signal: stop.signal,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toBeDefined()
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("timed out waiting for workspace.test event"))
|
||||
}, 3000)
|
||||
|
||||
void parseSSE(response.body!, stop.signal, (event) => {
|
||||
seen.push(event)
|
||||
const next = event as { type?: string }
|
||||
if (next.type === "server.connected") {
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: "workspace.test",
|
||||
properties: { ok: true },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (next.type !== "workspace.test") return
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
await done
|
||||
|
||||
expect(seen.some((event) => (event as { type?: string }).type === "server.connected")).toBe(true)
|
||||
expect(seen).toContainEqual({
|
||||
type: "workspace.test",
|
||||
properties: { ok: true },
|
||||
})
|
||||
} finally {
|
||||
stop.abort()
|
||||
}
|
||||
})
|
||||
})
|
||||
97
packages/opencode/test/control-plane/workspace-sync.test.ts
Normal file
97
packages/opencode/test/control-plane/workspace-sync.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const seen: string[] = []
|
||||
const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config
|
||||
|
||||
mock.module("../../src/control-plane/adaptors", () => ({
|
||||
getAdaptor: (config: { type: string }) => {
|
||||
seen.push(config.type)
|
||||
return {
|
||||
async create() {
|
||||
throw new Error("not used")
|
||||
},
|
||||
async remove() {},
|
||||
async request() {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe("control-plane/workspace.startSyncing", () => {
|
||||
test("syncs only remote workspaces and emits remote SSE events", async () => {
|
||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
const id1 = Identifier.descending("workspace")
|
||||
const id2 = Identifier.descending("workspace")
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(WorkspaceTable)
|
||||
.values([
|
||||
{
|
||||
id: id1,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
config: remote,
|
||||
},
|
||||
{
|
||||
id: id2,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
config: { type: "worktree", directory: tmp.path },
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const listener = (event: { directory?: string; payload: { type: string } }) => {
|
||||
if (event.directory !== id1) return
|
||||
if (event.payload.type !== "remote.ready") return
|
||||
GlobalBus.off("event", listener)
|
||||
resolve()
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
})
|
||||
|
||||
const sync = Workspace.startSyncing(project)
|
||||
await Promise.race([
|
||||
done,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for sync event")), 2000)),
|
||||
])
|
||||
|
||||
await sync.stop()
|
||||
expect(seen).toContain("testing")
|
||||
expect(seen).not.toContain("worktree")
|
||||
})
|
||||
})
|
||||
11
packages/opencode/test/fixture/db.ts
Normal file
11
packages/opencode/test/fixture/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { rm } from "fs/promises"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Database } from "../../src/storage/db"
|
||||
|
||||
export async function resetDatabase() {
|
||||
await Instance.disposeAll().catch(() => undefined)
|
||||
Database.close()
|
||||
await rm(Database.Path, { force: true }).catch(() => undefined)
|
||||
await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined)
|
||||
await rm(`${Database.Path}-shm`, { force: true }).catch(() => undefined)
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import type {
|
||||
EventTuiToastShow,
|
||||
ExperimentalResourceListResponses,
|
||||
ExperimentalSessionListResponses,
|
||||
ExperimentalWorkspaceCreateErrors,
|
||||
ExperimentalWorkspaceCreateResponses,
|
||||
ExperimentalWorkspaceListResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
@@ -901,6 +906,107 @@ export class Worktree extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Workspace extends HeyApiClient {
|
||||
/**
|
||||
* Remove workspace
|
||||
*
|
||||
* Remove an existing workspace.
|
||||
*/
|
||||
public remove<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
id: string
|
||||
directory?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "id" },
|
||||
{ in: "query", key: "directory" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).delete<
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/experimental/workspace/{id}",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workspace
|
||||
*
|
||||
* Create a workspace for the current project.
|
||||
*/
|
||||
public create<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
id: string
|
||||
directory?: string
|
||||
branch?: string | null
|
||||
config?: {
|
||||
directory: string
|
||||
type: "worktree"
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "id" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "body", key: "branch" },
|
||||
{ in: "body", key: "config" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
ExperimentalWorkspaceCreateResponses,
|
||||
ExperimentalWorkspaceCreateErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/experimental/workspace/{id}",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List workspaces
|
||||
*
|
||||
* List all workspaces.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
|
||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Session extends HeyApiClient {
|
||||
/**
|
||||
* List sessions
|
||||
@@ -965,6 +1071,11 @@ export class Resource extends HeyApiClient {
|
||||
}
|
||||
|
||||
export class Experimental extends HeyApiClient {
|
||||
private _workspace?: Workspace
|
||||
get workspace(): Workspace {
|
||||
return (this._workspace ??= new Workspace({ client: this.client }))
|
||||
}
|
||||
|
||||
private _session?: Session
|
||||
get session(): Session {
|
||||
return (this._session ??= new Session({ client: this.client }))
|
||||
|
||||
@@ -887,6 +887,35 @@ export type EventVcsBranchUpdated = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeReady = {
|
||||
type: "worktree.ready"
|
||||
properties: {
|
||||
name: string
|
||||
branch: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeFailed = {
|
||||
type: "worktree.failed"
|
||||
properties: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceReady = {
|
||||
type: "workspace.ready"
|
||||
properties: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceFailed = {
|
||||
type: "workspace.failed"
|
||||
properties: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Pty = {
|
||||
id: string
|
||||
title: string
|
||||
@@ -926,21 +955,6 @@ export type EventPtyDeleted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeReady = {
|
||||
type: "worktree.ready"
|
||||
properties: {
|
||||
name: string
|
||||
branch: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeFailed = {
|
||||
type: "worktree.failed"
|
||||
properties: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
@@ -979,12 +993,14 @@ export type Event =
|
||||
| EventSessionDiff
|
||||
| EventSessionError
|
||||
| EventVcsBranchUpdated
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventPtyCreated
|
||||
| EventPtyUpdated
|
||||
| EventPtyExited
|
||||
| EventPtyDeleted
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
@@ -1627,6 +1643,16 @@ export type WorktreeCreateInput = {
|
||||
startCommand?: string
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
id: string
|
||||
branch: string | null
|
||||
projectID: string
|
||||
config: {
|
||||
directory: string
|
||||
type: "worktree"
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
directory: string
|
||||
}
|
||||
@@ -2473,6 +2499,93 @@ export type WorktreeCreateResponses = {
|
||||
|
||||
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
|
||||
|
||||
export type ExperimentalWorkspaceRemoveData = {
|
||||
body?: never
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/experimental/workspace/{id}"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceRemoveErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceRemoveError =
|
||||
ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors]
|
||||
|
||||
export type ExperimentalWorkspaceRemoveResponses = {
|
||||
/**
|
||||
* Workspace removed
|
||||
*/
|
||||
200: Workspace
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceRemoveResponse =
|
||||
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
|
||||
|
||||
export type ExperimentalWorkspaceCreateData = {
|
||||
body?: {
|
||||
branch: string | null
|
||||
config: {
|
||||
directory: string
|
||||
type: "worktree"
|
||||
}
|
||||
}
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/experimental/workspace/{id}"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceCreateErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceCreateError =
|
||||
ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors]
|
||||
|
||||
export type ExperimentalWorkspaceCreateResponses = {
|
||||
/**
|
||||
* Workspace created
|
||||
*/
|
||||
200: Workspace
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceCreateResponse =
|
||||
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
|
||||
|
||||
export type ExperimentalWorkspaceListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/experimental/workspace"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceListResponses = {
|
||||
/**
|
||||
* Workspaces
|
||||
*/
|
||||
200: Array<Workspace>
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceListResponse =
|
||||
ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]
|
||||
|
||||
export type WorktreeResetData = {
|
||||
body?: WorktreeResetInput
|
||||
path?: never
|
||||
|
||||
@@ -79,6 +79,32 @@ export const multiply = tool({
|
||||
|
||||
---
|
||||
|
||||
#### 与内置工具的名称冲突
|
||||
|
||||
自定义工具通过工具名称进行索引。如果自定义工具使用了与内置工具相同的名称,则优先使用自定义工具。
|
||||
|
||||
例如,这个文件取代了内置的bash工具:
|
||||
|
||||
```ts title=".opencode/tools/bash.ts"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export default tool({
|
||||
description: "Restricted bash wrapper",
|
||||
args: {
|
||||
command: tool.schema.string(),
|
||||
},
|
||||
async execute(args) {
|
||||
return `blocked: ${args.command}`
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
:::note
|
||||
除非你有意替换内置工具,否则最好用独特的名字。如果你想禁用内置工具但不想覆盖它,使用 [权限](/docs/permissions).
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### 参数
|
||||
|
||||
你可以使用 `tool.schema`(即 [Zod](https://zod.dev))来定义参数类型。
|
||||
|
||||
@@ -27,6 +27,7 @@ OpenCode 内置了多种适用于主流语言的 LSP 服务器:
|
||||
| gopls | .go | 需要 `go` 命令可用 |
|
||||
| hls | .hs, .lhs | 需要 `haskell-language-server-wrapper` 命令可用 |
|
||||
| jdtls | .java | 需要已安装 `Java SDK (version 21+)` |
|
||||
| julials | .jl | 需要安装 `julia` and `LanguageServer.jl` |
|
||||
| kotlin-ls | .kt, .kts | 为 Kotlin 项目自动安装 |
|
||||
| lua-ls | .lua | 为 Lua 项目自动安装 |
|
||||
| nixd | .nix | 需要 `nixd` 命令可用 |
|
||||
|
||||
@@ -307,6 +307,10 @@ export const CustomToolsPlugin: Plugin = async (ctx) => {
|
||||
|
||||
你的自定义工具将与内置工具一起在 OpenCode 中可用。
|
||||
|
||||
:::note
|
||||
如果插件工具与内置工具使用相同的名称,则优先使用插件工具。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### 日志记录
|
||||
|
||||
@@ -131,6 +131,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过
|
||||
|
||||
2. 使用以下方法之一**配置身份验证**:
|
||||
|
||||
***
|
||||
|
||||
#### 环境变量(快速上手)
|
||||
|
||||
运行 opencode 时设置以下环境变量之一:
|
||||
@@ -153,6 +155,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
#### 配置文件(推荐)
|
||||
|
||||
如需项目级别或持久化的配置,请使用 `opencode.json`:
|
||||
@@ -180,6 +184,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过
|
||||
配置文件中的选项优先级高于环境变量。
|
||||
:::
|
||||
|
||||
***
|
||||
|
||||
#### 进阶:VPC 端点
|
||||
|
||||
如果你使用 Bedrock 的 VPC 端点:
|
||||
@@ -203,12 +209,16 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过
|
||||
`endpoint` 选项是通用 `baseURL` 选项的别名,使用了 AWS 特有的术语。如果同时指定了 `endpoint` 和 `baseURL`,则 `endpoint` 优先。
|
||||
:::
|
||||
|
||||
***
|
||||
|
||||
#### 认证方式
|
||||
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**:在 AWS 控制台中创建 IAM 用户并生成访问密钥
|
||||
- **`AWS_PROFILE`**:使用 `~/.aws/credentials` 中的命名配置文件。需要先通过 `aws configure --profile my-profile` 或 `aws sso login` 进行配置
|
||||
- **`AWS_BEARER_TOKEN_BEDROCK`**:从 Amazon Bedrock 控制台生成长期 API 密钥
|
||||
- **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**:适用于 EKS IRSA(服务账户的 IAM 角色)或其他支持 OIDC 联合的 Kubernetes 环境。使用服务账户注解时,Kubernetes 会自动注入这些环境变量。
|
||||
|
||||
***
|
||||
|
||||
#### 认证优先级
|
||||
|
||||
Amazon Bedrock 使用以下认证优先级:
|
||||
|
||||
@@ -234,7 +234,7 @@ How is auth handled in @packages/functions/src/api/index.ts?
|
||||
列出可用主题。
|
||||
|
||||
```bash frame="none"
|
||||
/theme
|
||||
/themes
|
||||
```
|
||||
|
||||
**快捷键:** `ctrl+x t`
|
||||
|
||||
@@ -64,19 +64,22 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 Free | glm-5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -104,42 +107,47 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
我们支持按量付费模式。以下是**每 100 万 Token** 的价格。
|
||||
|
||||
| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 |
|
||||
| -------------------------------- | ------ | ------ | -------- | -------- |
|
||||
| Big Pickle | 免费 | 免费 | 免费 | - |
|
||||
| MiniMax M2.5 Free | 免费 | 免费 | 免费 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 Free | 免费 | 免费 | 免费 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
| Claude Sonnet 4.5 (≤ 200K Token) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K Token) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4 (≤ 200K Token) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4 (> 200K Token) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
|
||||
| Claude Opus 4.6 (≤ 200K Token) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K Token) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Gemini 3 Pro (≤ 200K Token) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K Token) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | 免费 | 免费 | 免费 | - |
|
||||
| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 |
|
||||
| --------------------------------- | ------ | ------ | -------- | -------- |
|
||||
| Big Pickle | 免费 | 免费 | 免费 | - |
|
||||
| MiniMax M2.5 Free | 免费 | 免费 | 免费 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 Free | Free | Free | Free | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 Free | 免费 | 免费 | 免费 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
|
||||
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | 免费 | 免费 | 免费 | - |
|
||||
|
||||
你可能会在使用记录中看到 _Claude Haiku 3.5_。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。
|
||||
|
||||
@@ -149,6 +157,7 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
免费模型说明:
|
||||
|
||||
- GLM 5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Kimi K2.5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- MiniMax M2.5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Big Pickle 是一个隐身模型,在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
@@ -178,6 +187,7 @@ https://opencode.ai/zen/v1/models
|
||||
我们所有的模型都托管在美国。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外:
|
||||
|
||||
- Big Pickle:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- GLM 5 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Kimi K2.5 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- OpenAI API:请求会根据 [OpenAI 数据政策](https://platform.openai.com/docs/guides/your-data)保留 30 天。
|
||||
|
||||
Reference in New Issue
Block a user