Compare commits

..
Author SHA1 Message Date
Shoubhit Dash 5a7f03a384 refactor: remove legacy edit mode 2026-03-06 11:10:00 +05:30
Shoubhit DashandGitHub 9215af4465 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-03-06 10:19:52 +05:30
opencode cb411248bf release: v1.2.19 2026-03-06 04:29:10 +00:00
SadıkandGitHub 46d7d2fdc0 feat: add "gpt-5.4" to codex allowed models list (#16274) 2026-03-05 23:03:01 -05:00
Dax Raad d68afcaa55 refactor: replace Bun.stderr and Bun.color with Node.js equivalents 2026-03-05 22:20:16 -05:00
Dax Raad bf35a865ba refactor: replace Bun.connect with net.createConnection 2026-03-05 22:17:08 -05:00
Dax Raad 6733a5a822 fix: use sha1 for hash instead of unsupported xxhash3-xxh64 2026-03-05 22:12:10 -05:00
Dax Raad 7e28098365 refactor: use node:stream/consumers for stdin reading 2026-03-05 22:08:50 -05:00
Dax Raad ae5c9ed3dd refactor: replace Bun.stdin.text with Node.js stream reading 2026-03-05 22:04:20 -05:00
Dax Raad a9bf1c0505 refactor: replace Bun.hash with Hash.fast using xxhash3-xxh64 2026-03-05 22:03:24 -05:00
Dax Raad dad248832d refactor: replace Bun.write with Filesystem.write in config files 2026-03-05 21:59:20 -05:00
Dax Raad 6e89d3e597 refactor: replace Bun.write/file with Filesystem utilities in snapshot 2026-03-05 21:56:41 -05:00
DaxandGitHub 3ebba02d04 refactor: replace Bun.sleep with node timers (#15013)
publish-github-action / publish (push) Has been cancelled
2026-03-05 21:54:06 -05:00
FilipandGitHub cf425d114e fix(app): stale show (#16236) 2026-03-05 18:23:48 -06:00
David Hill 39691e5174 tui: remove keyboard shortcut tooltips from new session and new workspace buttons in the sidebar 2026-03-05 23:44:00 +00:00
Frank adaee66364 zen: gpt 5.4 pro 2026-03-05 18:39:17 -05:00
Frank a6978167ae ci: fix 2026-03-05 15:46:17 -05:00
Frank 80c36c788c zen: gpt5.3 codex spark 2026-03-05 15:40:51 -05:00
JunandGitHub 76cdc668e8 fix(console): follow-up for #13108 docs/en routing and locale cookie sync (#13608) 2026-03-05 14:31:38 -06:00
Adam 2ba1ecabc9 fix(app): load tab on open file 2026-03-05 13:40:25 -06:00
opencode-agent[bot]andopencode e3b6d84b57 docs(i18n): sync locale docs from english changes 2026-03-05 19:01:10 +00:00
Frankandopencode 0638e49b02 zen: gpt5.4 2026-03-05 19:01:10 +00:00
Shoubhit DashandGitHub ab44597018 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 11:52:55 +05:30
Shoubhit Dash aec95c4d10 stabilize hashline routing and anchors 2026-02-27 09:45:06 +05:30
Shoubhit DashandGitHub b2c82cb897 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 08:55:43 +05:30
Shoubhit DashandGitHub 52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash 3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash 9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash 56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
85 changed files with 2414 additions and 1313 deletions
+16 -16
View File
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.18",
"version": "1.2.19",
"bin": {
"opencode": "./bin/opencode",
},
@@ -407,7 +407,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -427,7 +427,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.18",
"version": "1.2.19",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -438,7 +438,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -473,7 +473,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -519,7 +519,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"zod": "catalog:",
},
@@ -530,7 +530,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.18",
"version": "1.2.19",
"description": "",
"type": "module",
"exports": {
+11 -23
View File
@@ -22,7 +22,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
@@ -1937,20 +1937,14 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
@@ -1965,15 +1959,9 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
+1
View File
@@ -720,6 +720,7 @@ export default function Page() {
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})
@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
})
})
+8 -3
View File
@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const openTab = () => input.openTab(input.tabForPath(path))
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
else open()
})
}
}
@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
+38 -1
View File
@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
"zh-tw",
])
const DOCS_LOCALE = {
ar: "ar",
da: "da",
de: "de",
en: "en",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nb: "no",
"pt-br": "br",
root: "en",
ru: "ru",
th: "th",
tr: "tr",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (value === "root") {
if (next.path === "/docs/en") return `/docs${next.suffix}`
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
return `${next.path}${next.suffix}`
}
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
return parseLocale(fix(pathname).split("/")[1])
}
export function fromDocsPathname(pathname: string) {
const next = fix(pathname)
const value = next.split("/")[2]?.toLowerCase()
if (!value) return null
if (!next.startsWith("/docs/")) return null
if (!(value in DOCS_LOCALE)) return null
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
}
export function strip(pathname: string) {
const locale = fromPathname(pathname)
if (!locale) return fix(pathname)
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
const fromPath = fromPathname(new URL(request.url).pathname)
if (fromPath) return fromPath
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
if (fromDocsPath) return fromDocsPath
return (
localeFromCookieHeader(request.headers.get("cookie")) ??
detectFromAcceptLanguage(request.headers.get("accept-language"))
@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler
@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.18",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.18",
"version": "1.2.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.18",
"version": "1.2.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.18",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.18"
version = "1.2.19"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.18",
"version": "1.2.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.18",
"version": "1.2.19",
"name": "opencode",
"type": "module",
"license": "MIT",
+50 -31
View File
@@ -31,6 +31,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -281,7 +282,7 @@ export namespace ACP {
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = String(Bun.hash(output))
const hash = Hash.fast(output)
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection
@@ -346,21 +347,13 @@ export namespace ACP {
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
const diff = editDiff(part.state.input, part.state.metadata)
if (diff) {
content.push({
type: "diff",
...diff,
})
}
}
if (part.tool === "todowrite") {
@@ -861,21 +854,13 @@ export namespace ACP {
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
const diff = editDiff(part.state.input, part.state.metadata)
if (diff) {
content.push({
type: "diff",
...diff,
})
}
}
if (part.tool === "todowrite") {
@@ -1629,6 +1614,40 @@ export namespace ACP {
}
}
function editDiff(input: Record<string, any>, metadata: unknown) {
const meta = typeof metadata === "object" && metadata ? (metadata as Record<string, any>) : undefined
const filediff =
meta && typeof meta["filediff"] === "object" && meta["filediff"]
? (meta["filediff"] as Record<string, any>)
: undefined
const path =
typeof filediff?.["file"] === "string"
? filediff["file"]
: typeof input["filePath"] === "string"
? input["filePath"]
: ""
const oldText =
typeof filediff?.["before"] === "string"
? filediff["before"]
: typeof input["oldString"] === "string"
? input["oldString"]
: ""
const newText =
typeof filediff?.["after"] === "string"
? filediff["after"]
: typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
if (!path && !oldText && !newText) return
return {
path,
oldText,
newText,
}
}
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
const result = applyPatch(fileOriginal, unifiedDiff)
if (result === false) {
+2 -1
View File
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await Bun.sleep(10)
await sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
+2 -1
View File
@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await Bun.sleep(1000)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},
+3 -2
View File
@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await Bun.sleep(1000)
await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e
+2 -1
View File
@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
@@ -2065,7 +2065,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit {normalizePath(props.input.filePath!)} {input(props.input, ["edits", "filePath"])}
</InlineTool>
</Match>
</Switch>
+2 -1
View File
@@ -3,6 +3,7 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -53,7 +54,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
+3 -2
View File
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
await sleep(250)
continue
}
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
await Bun.sleep(250)
await sleep(250)
}
}
})().catch((error) => {
+3 -3
View File
@@ -25,12 +25,12 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
Bun.stderr.write(EOL)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
Bun.stderr.write(message.join(" "))
process.stderr.write(message.join(" "))
}
let blank = false
@@ -44,7 +44,7 @@ export namespace UI {
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: Bun.color("gray", "ansi") ?? "",
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
+9 -1
View File
@@ -1151,6 +1151,12 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
hashline_autocorrect: z
.boolean()
.optional()
.describe(
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
),
openTelemetry: z
.boolean()
.optional()
@@ -1240,7 +1246,7 @@ export namespace Config {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(options.path, updated).catch(() => {})
await Filesystem.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin && isFile) {
@@ -1401,3 +1407,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write
@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
: await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
+7 -15
View File
@@ -1,3 +1,4 @@
import { createConnection } from "net"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -160,21 +161,12 @@ export namespace McpOAuthCallback {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
resolve(false)
})
})
+3 -1
View File
@@ -4,6 +4,7 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -361,6 +362,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.1-codex",
@@ -602,7 +604,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { type: "failed" as const }
}
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}
+4 -3
View File
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},
+4 -3
View File
@@ -6,9 +6,10 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
@@ -795,7 +796,7 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const sdk = new Map<number, SDK>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1085,7 +1086,7 @@ export namespace Provider {
...model.headers,
}
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing
@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:
+3 -2
View File
@@ -3,6 +3,7 @@ import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -22,13 +23,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
+5 -5
View File
@@ -1,6 +1,7 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
@@ -271,13 +272,12 @@ export namespace Snapshot {
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Bun.write(target, "")
await Filesystem.write(target, "")
return
}
const text = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(target, text)
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
}
async function excludes() {
+278 -563
View File
@@ -1,13 +1,9 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { createTwoFilesPatch, diffLines } from "diff"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
@@ -17,567 +13,325 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
import {
HashlineEdit,
applyHashlineEdits,
hashlineOnlyCreates,
parseHashlineContent,
serializeHashlineContent,
} from "./hashline"
import { Config } from "../config/config"
const MAX_DIAGNOSTICS_PER_FILE = 20
const HASHLINE_EDIT_MODE = "hashline"
const LEGACY_KEYS = ["oldString", "newString", "replaceAll"] as const
const EditParams = z
.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z.array(HashlineEdit).optional(),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.edits !== undefined) return
ctx.addIssue({
code: "custom",
message: "Hashline payload requires edits (use [] when only delete or rename is intended).",
})
})
type EditParams = z.infer<typeof EditParams>
function formatValidationError(error: z.ZodError) {
const legacy = error.issues.some((issue) => {
if (issue.code !== "unrecognized_keys") return false
if (!("keys" in issue) || !Array.isArray(issue.keys)) return false
return issue.keys.some((key) => LEGACY_KEYS.includes(key as (typeof LEGACY_KEYS)[number]))
})
if (legacy) {
return "Legacy edit payload has been removed. Use hashline fields: { filePath, edits, delete?, rename? }."
}
return `Invalid parameters for tool 'edit':\n${error.issues.map((issue) => `- ${issue.message}`).join("\n")}`
}
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
async function withLocks(paths: string[], fn: () => Promise<void>) {
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
const recurse = async (idx: number): Promise<void> => {
if (idx >= unique.length) return fn()
await FileTime.withLock(unique[idx], () => recurse(idx + 1))
}
await recurse(0)
}
function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff {
const filediff: Snapshot.FileDiff = {
file,
before,
after,
additions: 0,
deletions: 0,
}
for (const change of diffLines(before, after)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
return filediff
}
async function diagnosticsOutput(filePath: string, output: string) {
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) {
return {
output,
diagnostics,
}
}
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
return {
output:
output +
`\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`,
diagnostics,
}
}
async function executeHashline(
params: EditParams,
ctx: Tool.Context,
autocorrect: boolean,
aggressiveAutocorrect: boolean,
) {
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
const targetPath = params.rename
? path.isAbsolute(params.rename)
? params.rename
: path.join(Instance.directory, params.rename)
: sourcePath
const edits = params.edits ?? []
await assertExternalDirectory(ctx, sourcePath)
if (params.rename) {
await assertExternalDirectory(ctx, targetPath)
}
if (params.delete && edits.length > 0) {
throw new Error("delete=true cannot be combined with edits")
}
if (params.delete && params.rename) {
throw new Error("delete=true cannot be combined with rename")
}
let diff = ""
let before = ""
let after = ""
let noop = 0
let deleted = false
let changed = false
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
await withLocks([sourcePath, targetPath], async () => {
const sourceStat = Filesystem.stat(sourcePath)
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
const exists = Boolean(sourceStat)
if (params.rename && !exists) {
throw new Error("rename requires an existing source file")
}
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
FileTime.read(ctx.sessionID, filePath)
if (params.delete) {
if (!exists) {
noop = 1
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.assert(ctx.sessionID, sourcePath)
before = await Filesystem.readText(sourcePath)
diff = trimDiff(createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), ""))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
patterns: [path.relative(Instance.worktree, sourcePath)],
always: ["*"],
metadata: {
filepath: filePath,
filepath: sourcePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: filePath,
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
file: sourcePath,
event: "unlink",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
deleted = true
changed = true
return
}
if (!exists && !hashlineOnlyCreates(edits)) {
throw new Error("Missing file can only be created with append/prepend hashline edits")
}
if (exists) {
await FileTime.assert(ctx.sessionID, sourcePath)
}
const parsed = exists
? parseHashlineContent(await Filesystem.readBytes(sourcePath))
: {
bom: false,
eol: "\n",
trailing: false,
lines: [] as string[],
text: "",
raw: "",
}
before = parsed.raw
const next = applyHashlineEdits({
lines: parsed.lines,
trailing: parsed.trailing,
edits,
autocorrect,
aggressiveAutocorrect,
})
const output = serializeHashlineContent({
lines: next.lines,
trailing: next.trailing,
eol: parsed.eol,
bom: parsed.bom,
})
after = output.text
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
if (before === after && sourcePath === targetPath) {
noop = 1
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
return
}
ctx.metadata({
diff = trimDiff(
createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
const patterns = [path.relative(Instance.worktree, sourcePath)]
if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath))
await ctx.ask({
permission: "edit",
patterns: Array.from(new Set(patterns)),
always: ["*"],
metadata: {
filepath: sourcePath,
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
if (sourcePath === targetPath) {
await Filesystem.write(sourcePath, output.bytes)
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: exists ? "change" : "add",
})
FileTime.read(ctx.sessionID, sourcePath)
changed = true
return
}
const targetExists = await Filesystem.exists(targetPath)
await Filesystem.write(targetPath, output.bytes)
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(File.Event.Edited, {
file: targetPath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: "unlink",
})
await Bus.publish(FileWatcher.Event.Updated, {
file: targetPath,
event: targetExists ? "change" : "add",
})
FileTime.read(ctx.sessionID, targetPath)
changed = true
})
const file = deleted ? sourcePath : targetPath
const filediff = createFileDiff(file, before, after)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
})
if (!deleted && (changed || noop === 0)) {
const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.")
diagnostics = result.diagnostics
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
title: `${path.relative(Instance.worktree, targetPath)}`,
output: result.output,
}
}
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, file)}`,
output: deleted ? "Edit applied successfully." : "No changes applied.",
}
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: EditParams,
formatValidationError,
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
const config = await Config.get()
return executeHashline(
params,
ctx,
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
)
},
})
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
/**
* Levenshtein distance algorithm implementation
*/
function levenshtein(a: string, b: string): number {
// Handle empty strings
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
}
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length
if (k < searchLines.length - 1) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
candidates.push({ startLine: i, endLine: j })
break // Only match the first occurrence of the last line
}
}
}
// Return immediately if no candidates
if (candidates.length === 0) {
return
}
// Handle single candidate scenario (using relaxed threshold)
if (candidates.length === 1) {
const { startLine, endLine } = candidates[0]
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += (1 - distance / maxLen) / linesToCheck
// Exit early when threshold is reached
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
break
}
}
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
return
}
// Calculate similarity for multiple candidates
let bestMatch: { startLine: number; endLine: number } | null = null
let maxSimilarity = -1
for (const candidate of candidates) {
const { startLine, endLine } = candidate
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += 1 - distance / maxLen
}
similarity /= linesToCheck // Average similarity
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity > maxSimilarity) {
maxSimilarity = similarity
bestMatch = candidate
}
}
// Threshold judgment
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
} else {
// Only check for substring matches if the full line doesn't match
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
export function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
@@ -613,42 +367,3 @@ export function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
let notFound = true
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
if (notFound) {
throw new Error(
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
)
}
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
}
+19 -6
View File
@@ -1,10 +1,23 @@
Performs exact string replacements in files.
Performs file edits with hashline anchors.
Usage:
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
- `{ filePath, edits, delete?, rename? }`
- `edits` is required; use `[]` when only delete or rename is intended.
- Use strict anchor references from `Read` output: `LINE#ID`.
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
- Operations:
- `set_line { line, text }`
- `replace_lines { start_line, end_line, text }`
- `insert_after { line, text }`
- `insert_before { line, text }`
- `insert_between { after_line, before_line, text }`
- `append { text }`
- `prepend { text }`
- `replace { old_text, new_text, all? }`
- Provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
- GPT-family models can use `apply_patch` as fallback when needed.
+2 -1
View File
@@ -9,6 +9,7 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { hashlineRef } from "./hashline"
const MAX_LINE_LENGTH = 2000
@@ -129,7 +130,7 @@ export const GrepTool = Tool.define("grep", {
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
}
if (truncated) {
+2 -1
View File
@@ -2,7 +2,8 @@
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time
- Returns file paths with matching lines sorted by modification time
- Output format uses hashline anchors: `N#ID:<content>`
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
+646
View File
@@ -0,0 +1,646 @@
// hashline autocorrect heuristics in this file are inspired by
// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode.
import z from "zod"
export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
const HASHLINE_ID_LENGTH = 2
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/
type TextValue = string | string[]
export const HashlineText = z.union([z.string(), z.array(z.string())])
export const HashlineEdit = z.discriminatedUnion("type", [
z
.object({
type: z.literal("set_line"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace_lines"),
start_line: z.string(),
end_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_after"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_before"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_between"),
after_line: z.string(),
before_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("append"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("prepend"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace"),
old_text: z.string(),
new_text: HashlineText,
all: z.boolean().optional(),
})
.strict(),
])
export type HashlineEdit = z.infer<typeof HashlineEdit>
function isLowSignalContent(normalized: string) {
if (normalized.length === 0) return true
if (normalized.length <= 2) return true
return LOW_SIGNAL_CONTENT_RE.test(normalized)
}
export function hashlineID(lineNumber: number, line: string): string {
let normalized = line
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
normalized = normalized.replace(/\s+/g, "")
const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized
const hash = Bun.hash.xxHash32(seed) & 0xff
const high = (hash >>> 4) & 0x0f
const low = hash & 0x0f
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
}
export function hashlineRef(lineNumber: number, line: string): string {
return `${lineNumber}#${hashlineID(lineNumber, line)}`
}
export function hashlineLine(lineNumber: number, line: string): string {
return `${hashlineRef(lineNumber, line)}:${line}`
}
export function parseHashlineRef(input: string, label: string) {
const match = input.match(HASHLINE_REF_REGEX)
if (!match) {
throw new Error(`${label} must contain a LINE#ID reference`)
}
const line = Number.parseInt(match[1], 10)
if (!Number.isInteger(line) || line < 1) {
throw new Error(`${label} has invalid line number: ${match[1]}`)
}
const id = match[2]
if (!HASHLINE_ID_REGEX.test(id)) {
throw new Error(`${label} has invalid hash id: ${id}`)
}
return {
raw: `${line}#${id}`,
line,
id,
}
}
function toLines(text: TextValue) {
if (Array.isArray(text)) return text
return text.split(/\r?\n/)
}
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/
function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) {
const nonEmpty = lines.filter((line) => line.length > 0)
if (nonEmpty.length === 0) return lines
const matches = nonEmpty.filter(test).length
if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
return lines.map(rewrite)
}
function stripNewLinePrefixes(lines: string[]) {
const stripped = stripByMajority(
lines,
(line) => HASHLINE_PREFIX_RE.test(line),
(line) => line.replace(HASHLINE_PREFIX_RE, ""),
)
return stripByMajority(
stripped,
(line) => WRAPPER_PREFIX_RE.test(line),
(line) => line.replace(WRAPPER_PREFIX_RE, ""),
)
}
function equalsIgnoringWhitespace(a: string, b: string) {
if (a === b) return true
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
}
function leadingWhitespace(line: string) {
const match = line.match(/^\s*/)
if (!match) return ""
return match[0]
}
function restoreLeadingIndent(template: string, line: string) {
if (line.length === 0) return line
const templateIndent = leadingWhitespace(template)
if (templateIndent.length === 0) return line
const indent = leadingWhitespace(line)
if (indent.length > 0) return line
return templateIndent + line
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) {
if (oldLines.length !== newLines.length) return newLines
let changed = false
const out = new Array<string>(newLines.length)
for (let idx = 0; idx < newLines.length; idx++) {
const restored = restoreLeadingIndent(oldLines[idx], newLines[idx])
out[idx] = restored
if (restored !== newLines[idx]) changed = true
}
if (changed) return out
return newLines
}
function stripAllWhitespace(s: string) {
return s.replace(/\s+/g, "")
}
function restoreOldWrappedLines(oldLines: string[], newLines: string[]) {
if (oldLines.length === 0 || newLines.length < 2) return newLines
const canonToOld = new Map<string, { line: string; count: number }>()
for (const line of oldLines) {
const canon = stripAllWhitespace(line)
const bucket = canonToOld.get(canon)
if (bucket) bucket.count++
if (!bucket) canonToOld.set(canon, { line, count: 1 })
}
const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = []
for (let start = 0; start < newLines.length; start++) {
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""))
const old = canonToOld.get(canonSpan)
if (old && old.count === 1 && canonSpan.length >= 6) {
candidates.push({
start,
len,
replacement: old.line,
canon: canonSpan,
})
}
}
}
if (candidates.length === 0) return newLines
const canonCounts = new Map<string, number>()
for (const candidate of candidates) {
canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1)
}
const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1)
if (unique.length === 0) return newLines
unique.sort((a, b) => b.start - a.start)
const out = [...newLines]
for (const candidate of unique) {
out.splice(candidate.start, candidate.len, candidate.replacement)
}
return out
}
function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1)
return lines
}
function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1)
return lines
}
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) {
let out = lines
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1)
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1)
return out
}
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) {
const count = endLine - startLine + 1
if (lines.length <= 1 || lines.length <= count) return lines
let out = lines
const beforeIdx = startLine - 2
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
out = out.slice(1)
}
const afterIdx = endLine
if (
afterIdx < fileLines.length &&
out.length > 0 &&
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
) {
out = out.slice(0, -1)
}
return out
}
function ensureText(text: TextValue, label: string) {
const value = Array.isArray(text) ? text.join("") : text
if (value.length > 0) return
throw new Error(`${label} must be non-empty`)
}
function applyReplace(content: string, oldText: string, newText: TextValue, all = false) {
if (oldText.length === 0) throw new Error("replace.old_text must be non-empty")
const next = toLines(newText).join("\n")
const first = content.indexOf(oldText)
if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`)
if (all) return content.replaceAll(oldText, next)
const last = content.lastIndexOf(oldText)
if (first !== last) {
throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.")
}
return content.slice(0, first) + next + content.slice(first + oldText.length)
}
function mismatchContext(lines: string[], line: number) {
if (lines.length === 0) return ">>> (file is empty)"
const start = Math.max(1, line - 1)
const end = Math.min(lines.length, line + 1)
return Array.from({ length: end - start + 1 }, (_, idx) => start + idx)
.map((num) => {
const marker = num === line ? ">>>" : " "
return `${marker} ${hashlineLine(num, lines[num - 1])}`
})
.join("\n")
}
function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`
}
return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`
}
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
const seen = new Set<string>()
const unique = mismatches.filter((mismatch) => {
const key = `${mismatch.expected}:${mismatch.line}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch))
const hidden = unique.length - preview.length
const count = unique.length
const linesOut = [
`Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`,
...preview,
...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []),
]
if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") {
const body = unique
.map((mismatch) => {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return [
`>>> expected ${mismatch.expected}`,
`>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`,
].join("\n")
}
return [
`>>> expected ${mismatch.expected}`,
mismatchContext(lines, mismatch.line),
`>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`,
].join("\n")
})
.join("\n\n")
linesOut.push("", body)
}
throw new Error(linesOut.join("\n"))
}
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
const mismatches = refs
.filter((ref) => {
if (ref.line < 1 || ref.line > lines.length) return true
return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id
})
.map((ref) => ({ expected: ref.raw, line: ref.line }))
if (mismatches.length > 0) throwMismatch(lines, mismatches)
}
function splitLines(text: string) {
if (text === "") {
return {
lines: [] as string[],
trailing: false,
}
}
const trailing = text.endsWith("\n")
const lines = text.split(/\r?\n/)
if (trailing) lines.pop()
return { lines, trailing }
}
export function parseHashlineContent(bytes: Buffer) {
const raw = bytes.toString("utf8")
let text = raw
const bom = raw.startsWith("\uFEFF")
if (bom) text = raw.slice(1)
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const { lines, trailing } = splitLines(text)
return {
bom,
eol,
trailing,
lines,
text,
raw,
}
}
export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) {
let text = input.lines.join(input.eol)
if (input.trailing && input.lines.length > 0) text += input.eol
if (input.bom) text = `\uFEFF${text}`
return {
text,
bytes: Buffer.from(text, "utf8"),
}
}
type Splice = {
start: number
del: number
text: string[]
order: number
kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend"
sortLine: number
precedence: number
startLine?: number
endLine?: number
anchorLine?: number
beforeLine?: number
afterLine?: number
}
export function applyHashlineEdits(input: {
lines: string[]
trailing: boolean
edits: HashlineEdit[]
autocorrect?: boolean
aggressiveAutocorrect?: boolean
}) {
const lines = [...input.lines]
const originalLines = [...input.lines]
let trailing = input.trailing
const refs: Array<{ raw: string; line: number; id: string }> = []
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
const ops: Splice[] = []
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const parseText = (text: TextValue) => {
const next = toLines(text)
if (!autocorrect) return next
return stripNewLinePrefixes(next)
}
input.edits.forEach((edit, order) => {
if (edit.type === "replace") {
replaceOps.push(edit)
return
}
if (edit.type === "append") {
ensureText(edit.text, "append.text")
ops.push({
start: lines.length,
del: 0,
text: parseText(edit.text),
order,
kind: "append",
sortLine: lines.length + 1,
precedence: 1,
})
return
}
if (edit.type === "prepend") {
ensureText(edit.text, "prepend.text")
ops.push({
start: 0,
del: 0,
text: parseText(edit.text),
order,
kind: "prepend",
sortLine: 0,
precedence: 2,
})
return
}
if (edit.type === "set_line") {
const line = parseHashlineRef(edit.line, "set_line.line")
refs.push(line)
ops.push({
start: line.line - 1,
del: 1,
text: parseText(edit.text),
order,
kind: "set_line",
sortLine: line.line,
precedence: 0,
startLine: line.line,
endLine: line.line,
})
return
}
if (edit.type === "replace_lines") {
const start = parseHashlineRef(edit.start_line, "replace_lines.start_line")
const end = parseHashlineRef(edit.end_line, "replace_lines.end_line")
refs.push(start)
refs.push(end)
if (start.line > end.line) {
throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line")
}
ops.push({
start: start.line - 1,
del: end.line - start.line + 1,
text: parseText(edit.text),
order,
kind: "replace_lines",
sortLine: end.line,
precedence: 0,
startLine: start.line,
endLine: end.line,
})
return
}
if (edit.type === "insert_after") {
const line = parseHashlineRef(edit.line, "insert_after.line")
ensureText(edit.text, "insert_after.text")
refs.push(line)
ops.push({
start: line.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_after",
sortLine: line.line,
precedence: 1,
anchorLine: line.line,
})
return
}
if (edit.type === "insert_before") {
const line = parseHashlineRef(edit.line, "insert_before.line")
ensureText(edit.text, "insert_before.text")
refs.push(line)
ops.push({
start: line.line - 1,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_before",
sortLine: line.line,
precedence: 2,
anchorLine: line.line,
})
return
}
const after = parseHashlineRef(edit.after_line, "insert_between.after_line")
const before = parseHashlineRef(edit.before_line, "insert_between.before_line")
ensureText(edit.text, "insert_between.text")
refs.push(after)
refs.push(before)
if (after.line >= before.line) {
throw new Error("insert_between.after_line must be less than insert_between.before_line")
}
ops.push({
start: after.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_between",
sortLine: before.line,
precedence: 3,
afterLine: after.line,
beforeLine: before.line,
})
})
validateAnchors(lines, refs)
const sorted = [...ops].sort((a, b) => {
if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine
if (a.precedence !== b.precedence) return a.precedence - b.precedence
return a.order - b.order
})
sorted.forEach((op) => {
if (op.start < 0 || op.start > lines.length) {
throw new Error(`line index ${op.start + 1} is out of range`)
}
let text = op.text
if (autocorrect && aggressiveAutocorrect) {
if (op.kind === "set_line" || op.kind === "replace_lines") {
const start = op.startLine ?? op.start + 1
const end = op.endLine ?? start + op.del - 1
const old = originalLines.slice(start - 1, end)
text = stripRangeBoundaryEcho(originalLines, start, end, text)
text = restoreOldWrappedLines(old, text)
text = restoreIndentForPairedReplacement(old, text)
}
if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) {
text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text)
}
if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) {
text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text)
}
if (op.kind === "insert_between" && op.afterLine && op.beforeLine) {
text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text)
}
}
lines.splice(op.start, op.del, ...text)
})
if (replaceOps.length > 0) {
const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}`
const replaced = replaceOps.reduce(
(acc, op) =>
applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all),
content,
)
const split = splitLines(replaced)
lines.splice(0, lines.length, ...split.lines)
trailing = split.trailing
}
return {
lines,
trailing,
}
}
export function hashlineOnlyCreates(edits: HashlineEdit[]) {
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
}
+26 -10
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import { EditTool } from "./edit"
import { WriteTool } from "./write"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
@@ -22,17 +23,32 @@ export const MultiEditTool = Tool.define("multiedit", {
}),
async execute(params, ctx) {
const tool = await EditTool.init()
const write = await WriteTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
for (const edit of params.edits) {
const result =
edit.oldString === ""
? await write.execute(
{
filePath: params.filePath,
content: edit.newString,
},
ctx,
)
: await tool.execute(
{
filePath: params.filePath,
edits: [
{
type: "replace",
old_text: edit.oldString,
new_text: edit.newString,
all: edit.replaceAll,
},
],
},
ctx,
)
results.push(result)
}
return {
+5 -1
View File
@@ -11,6 +11,7 @@ import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
import { hashlineRef } from "./hashline"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -156,6 +157,7 @@ export const ReadTool = Tool.define("read", {
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = []
const full: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
@@ -179,6 +181,7 @@ export const ReadTool = Tool.define("read", {
}
raw.push(line)
full.push(text)
bytes += size
}
} finally {
@@ -191,7 +194,8 @@ export const ReadTool = Tool.define("read", {
}
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
const lineNumber = index + offset
return `${hashlineRef(lineNumber, full[index])}:${line}`
})
const preview = raw.slice(0, 20).join("\n")
+3 -1
View File
@@ -7,7 +7,9 @@ Usage:
- To read later sections, call this tool again with a larger offset.
- Use the grep tool to find specific content in large files or files with long lines.
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Contents are returned with a line prefix.
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Any line longer than 2000 characters is truncated.
- Call this tool in parallel when you know there are multiple files you want to read.
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
+2 -4
View File
@@ -136,6 +136,8 @@ export namespace ToolRegistry {
agent?: Agent.Info,
) {
const tools = await all()
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
const result = await Promise.all(
tools
.filter((t) => {
@@ -144,11 +146,7 @@ export namespace ToolRegistry {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
return true
})
+7
View File
@@ -0,0 +1,7 @@
import { createHash } from "crypto"
export namespace Hash {
export function fast(input: string | Buffer): string {
return createHash("sha1").update(input).digest("hex")
}
}
@@ -102,6 +102,48 @@ test("loads JSONC config file", async () => {
})
})
test("parses experimental.hashline_autocorrect", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: {
hashline_autocorrect: true,
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_autocorrect).toBe(true)
},
})
})
test("ignores removed experimental.hashline_edit", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: {
hashline_edit: true,
hashline_autocorrect: true,
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_autocorrect).toBe(true)
expect((config.experimental as Record<string, unknown>)?.hashline_edit).toBeUndefined()
},
})
})
test("merges multiple config files with correct precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
+2 -1
View File
@@ -3,6 +3,7 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
@@ -15,7 +16,7 @@ afterAll(async () => {
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => {
Bun.gc(true)
await Bun.sleep(100)
await sleep(100)
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
if (!busy(error)) throw error
if (left <= 1) throw error
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
@@ -43,7 +44,7 @@ describe("pty", () => {
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -88,7 +89,7 @@ describe("pty", () => {
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -128,7 +129,7 @@ describe("pty", () => {
ctx.connId = 2
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(out.join("")).toContain("AAA")
} finally {
+2 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await Bun.sleep(10000)
await sleep(10000)
controller.enqueue(" World!")
controller.close()
},
+462 -434
View File
@@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
import { hashlineLine, hashlineRef } from "../../src/tool/hashline"
const ctx = {
sessionID: "test-edit-session",
@@ -18,479 +19,506 @@ const ctx = {
}
describe("tool.edit", () => {
describe("creating new files", () => {
test("creates new file when oldString is empty", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "newfile.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "new content",
},
ctx,
)
expect(result.metadata.diff).toContain("new content")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content")
},
})
test("rejects legacy payloads", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
test("creates new file with nested directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "nested file",
},
oldString: "b",
newString: "B",
} as any,
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested file")
},
})
})
test("emits add event for new files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "new.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
),
).rejects.toThrow("Legacy edit payload has been removed")
},
})
})
describe("editing existing files", () => {
test("replaces text in existing file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content here", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "old content",
newString: "new content",
},
ctx,
)
expect(result.output).toContain("Edit applied successfully")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content here")
},
})
test("replaces a single line", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
edits: [
{
filePath: filepath,
oldString: "old",
newString: "new",
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
ctx,
),
).rejects.toThrow("not found")
},
})
})
],
},
ctx,
)
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "same",
newString: "same",
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
test("throws error when oldString not found in file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "actual content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "not in file",
newString: "replacement",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("throws error when file was not read first (FileTime)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "content",
newString: "modified",
},
ctx,
),
).rejects.toThrow("You must read file")
},
})
})
test("throws error when file has been modified since read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Read first
FileTime.read(ctx.sessionID, filepath)
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8")
// Try to edit with the new content
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "modified externally",
newString: "edited",
},
ctx,
),
).rejects.toThrow("modified since it was last read")
},
})
})
test("replaces all occurrences with replaceAll option", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "foo",
newString: "qux",
replaceAll: true,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("qux bar qux baz qux")
},
})
})
test("emits change event for existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
expect(result.metadata.edit_mode).toBe("hashline")
},
})
})
describe("edge cases", () => {
test("handles multiline replacements", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line 2\nextra line",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
},
})
test("supports replace operations with all=true", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "foo bar foo baz foo", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
test("handles CRLF line endings", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "old",
newString: "new",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\r\nnew\r\nline3")
},
})
})
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
filePath: filepath,
oldString: "",
newString: "",
type: "replace",
old_text: "foo",
new_text: "qux",
all: true,
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
],
},
ctx,
)
test("throws error when path is directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "adir")
await fs.mkdir(dirpath)
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: dirpath,
oldString: "old",
newString: "new",
},
ctx,
),
).rejects.toThrow("directory")
},
})
})
test("tracks file diff statistics", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line a\nnew line b",
},
ctx,
)
expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath)
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
},
})
expect(await fs.readFile(filepath, "utf-8")).toBe("qux bar qux baz qux")
},
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "0", "utf-8")
test("supports range replacement and insert modes", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace_lines",
start_line: hashlineRef(2, "b"),
end_line: hashlineRef(3, "c"),
text: ["B", "C"],
},
{
type: "insert_before",
line: hashlineRef(2, "b"),
text: "x",
},
{
type: "insert_after",
line: hashlineRef(3, "c"),
text: "y",
},
],
},
ctx,
)
const edit = await EditTool.init()
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nx\nB\nC\ny\nd")
},
})
})
// Two concurrent edits
const promise1 = edit.execute(
test("creates missing files from append and prepend operations", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "created.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "prepend",
text: "start",
},
{
type: "append",
text: "end",
},
],
},
ctx,
)
expect(await fs.readFile(filepath, "utf-8")).toBe("start\nend")
},
})
})
test("requires a prior read for existing files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "content", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "1",
edits: [
{
type: "set_line",
line: hashlineRef(1, "content"),
text: "changed",
},
],
},
ctx,
)
),
).rejects.toThrow("You must read file")
},
})
})
// Need to read again since FileTime tracks per-session
FileTime.read(ctx.sessionID, filepath)
test("rejects files modified since read", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "original", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
const promise2 = edit.execute(
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "external", "utf-8")
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "2",
edits: [
{
type: "set_line",
line: hashlineRef(1, "original"),
text: "changed",
},
],
},
ctx,
)
),
).rejects.toThrow("modified since it was last read")
},
})
})
// Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2])
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
test("rejects missing files for non-append and non-prepend edits", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "missing.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace",
old_text: "a",
new_text: "b",
},
],
},
ctx,
),
).rejects.toThrow("Missing file can only be created")
},
})
})
test("rejects directories", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "adir"))
},
})
const filepath = path.join(tmp.path, "adir")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "append",
text: "x",
},
],
},
ctx,
),
).rejects.toThrow("directory")
},
})
})
test("tracks file diff statistics", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "line1\nline2\nline3", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace_lines",
start_line: hashlineRef(2, "line2"),
end_line: hashlineRef(2, "line2"),
text: ["new line a", "new line b"],
},
],
},
ctx,
)
expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath)
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
},
})
})
test("emits change events for existing files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
})
test("applies hashline autocorrect prefixes through config", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_autocorrect: true,
},
})
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: hashlineLine(2, "B"),
},
],
},
ctx,
)
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
},
})
})
test("supports delete and rename flows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8")
await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8")
},
})
const source = path.join(tmp.path, "src.txt")
const target = path.join(tmp.path, "renamed.txt")
const doomed = path.join(tmp.path, "delete.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
FileTime.read(ctx.sessionID, source)
await edit.execute(
{
filePath: source,
rename: target,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
expect(await fs.readFile(target, "utf-8")).toBe("a\nB")
await expect(fs.stat(source)).rejects.toThrow()
FileTime.read(ctx.sessionID, doomed)
await edit.execute(
{
filePath: doomed,
delete: true,
edits: [],
},
ctx,
)
await expect(fs.stat(doomed)).rejects.toThrow()
},
})
})
test("serializes concurrent edits to the same file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "0", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const first = edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(1, "0"),
text: "1",
},
],
},
ctx,
)
FileTime.read(ctx.sessionID, filepath)
const second = edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(1, "0"),
text: "2",
},
],
},
ctx,
)
const results = await Promise.allSettled([first, second])
expect(results.some((result) => result.status === "fulfilled")).toBe(true)
},
})
})
})
+23 -2
View File
@@ -37,6 +37,29 @@ describe("tool.grep", () => {
})
})
test("emits hashline anchors by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/)
},
})
})
test("no matches returns correct output", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -61,10 +84,8 @@ describe("tool.grep", () => {
})
test("handles CRLF line endings in output", async () => {
// This test verifies the regex split handles both \n and \r\n
await using tmp = await tmpdir({
init: async (dir) => {
// Create a test file with content
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})
@@ -0,0 +1,210 @@
import { describe, expect, test } from "bun:test"
import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline"
function swapID(ref: string) {
const [line, id] = ref.split("#")
const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}`
return `${line}#${next}`
}
function errorMessage(run: () => void) {
try {
run()
return ""
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
describe("tool.hashline", () => {
test("hash computation is stable and 2-char alphabet encoded", () => {
const a = hashlineID(1, " const x = 1")
const b = hashlineID(1, "constx=1")
const c = hashlineID(99, "constx=1")
expect(a).toBe(b)
expect(a).toBe(c)
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
test("low-signal lines mix line index into hash id", () => {
const a = hashlineID(1, "")
const b = hashlineID(2, "")
const c = hashlineID(1, "{}")
const d = hashlineID(2, "{}")
expect(a).not.toBe(b)
expect(c).not.toBe(d)
})
test("autocorrect strips copied hashline prefixes when enabled", () => {
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
try {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: hashlineLine(1, "a"),
},
],
})
expect(result.lines).toEqual(["a"])
} finally {
if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT
else Bun.env.OPENCODE_HL_AUTOCORRECT = old
}
})
test("default autocorrect does not rewrite non-prefix content", () => {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: "+a",
},
],
autocorrect: true,
aggressiveAutocorrect: false,
})
expect(result.lines).toEqual(["+a"])
})
test("parses strict LINE#ID references with tolerant extraction", () => {
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
expect(ref.line).toBe(12)
expect(ref.id).toBe("ZP")
expect(ref.raw).toBe("12#ZP")
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
})
test("reports compact mismatch errors with retry anchors", () => {
const lines = ["alpha", "beta", "gamma"]
const wrong = swapID(hashlineRef(2, lines[1]))
const message = errorMessage(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "set_line",
line: wrong,
text: "BETA",
},
],
}),
)
expect(message).toContain("anchor mismatch")
expect(message).toContain("retry with")
expect(message).not.toContain(">>>")
expect(message.length).toBeLessThan(260)
})
test("applies batched line edits bottom-up for stable results", () => {
const lines = ["a", "b", "c", "d"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
const three = hashlineRef(3, lines[2])
const four = hashlineRef(4, lines[3])
const result = applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: three,
text: ["B", "C"],
},
{
type: "insert_after",
line: one,
text: "A1",
},
{
type: "set_line",
line: four,
text: "D",
},
],
})
expect(result.lines).toEqual(["a", "A1", "B", "C", "D"])
})
test("orders append and prepend deterministically on empty files", () => {
const result = applyHashlineEdits({
lines: [],
trailing: false,
edits: [
{
type: "append",
text: "end",
},
{
type: "prepend",
text: "start",
},
],
})
expect(result.lines).toEqual(["start", "end"])
})
test("validates ranges, between constraints, and non-empty insert text", () => {
const lines = ["a", "b", "c"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: one,
text: "x",
},
],
}),
).toThrow("start_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "insert_between",
after_line: two,
before_line: one,
text: "x",
},
],
}),
).toThrow("insert_between.after_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "append",
text: "",
},
],
}),
).toThrow("append.text")
})
})
+26 -4
View File
@@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
import { hashlineLine } from "../../src/tool/hashline"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -269,10 +270,10 @@ describe("tool.read truncation", () => {
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain(hashlineLine(10, "line10"))
expect(result.output).toContain(hashlineLine(14, "line14"))
expect(result.output).not.toContain(hashlineLine(9, "line9"))
expect(result.output).not.toContain(hashlineLine(15, "line15"))
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
@@ -443,6 +444,27 @@ root_type Monster;`
})
})
describe("tool.read hashline output", () => {
test("returns LINE#ID prefixes by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "hashline.txt") }, ctx)
expect(result.output).toContain(hashlineLine(1, "foo"))
expect(result.output).toContain(hashlineLine(2, "bar"))
expect(result.output).not.toContain("1: foo")
},
})
})
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
describe("tool.registry hashline routing", () => {
test("keeps edit and apply_patch for GPT models", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).toContain("apply_patch")
},
})
})
test("keeps edit and removes apply_patch for non-GPT models", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).not.toContain("apply_patch")
},
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.18",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"exports": {
+2 -2
View File
@@ -1751,11 +1751,11 @@ ToolRegistry.register({
mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString,
contents: props.metadata?.filediff?.before ?? props.input.oldString,
}}
after={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.after || props.input.newString,
contents: props.metadata?.filediff?.after ?? props.input.newString,
}}
/>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.18",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.18",
"version": "1.2.19",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+15
View File
@@ -59,6 +59,7 @@ OpenCode Zen هو بوابة للذكاء الاصطناعي تتيح لك ال
| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -141,6 +142,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -184,6 +186,19 @@ https://opencode.ai/zen/v1/models
---
### نماذج مهملة
| النموذج | تاريخ الإيقاف |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6 فبراير 2026 |
| Kimi K2 Thinking | 6 مارس 2026 |
| Kimi K2 | 6 مارس 2026 |
| MiniMax M2.1 | 15 مارس 2026 |
| GLM 4.7 | 15 مارس 2026 |
| GLM 4.6 | 15 مارس 2026 |
---
## الخصوصية
تتم استضافة جميع نماذجنا في الولايات المتحدة. يلتزم مزوّدونا بسياسة عدم الاحتفاظ بالبيانات (zero-retention) ولا يستخدمون بياناتك لتدريب النماذج، مع الاستثناءات التالية:
+15
View File
@@ -55,6 +55,7 @@ Nasim modelima mozete pristupiti i preko sljedecih API endpointa.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Podrzavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Na primjer, ako postavite mjesecni limit na $20, Zen nece potrositi vise od $20
---
### Zastarjeli modeli
| Model | Datum ukidanja |
| ---------------- | -------------- |
| Qwen3 Coder 480B | 6. feb. 2026. |
| Kimi K2 Thinking | 6. mart 2026. |
| Kimi K2 | 6. mart 2026. |
| MiniMax M2.1 | 15. mart 2026. |
| GLM 4.7 | 15. mart 2026. |
| GLM 4.6 | 15. mart 2026. |
---
## Privatnost
Svi nasi modeli su hostovani u SAD-u. Provajderi prate zero-retention politiku i ne koriste vase podatke za treniranje modela, uz sljedece izuzetke:
+15
View File
@@ -64,6 +64,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| Model | Model ID | Endpoint | AI SDK Pakke |
| ------------------- | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-model. Nedenfor er priserne **per 1 million tokens*
| 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.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
@@ -192,6 +194,19 @@ at opkræve dig mere end $20, hvis din saldo går under $5.
---
### Udfasede modeller
| Model | Udfasningsdato |
| ---------------- | -------------- |
| Qwen3-koder 480B | 6. feb. 2026 |
| Kimi K2 Tenker | 6. marts 2026 |
| Kimi K2 | 6. marts 2026 |
| MiniMax M2.1 | 15. marts 2026 |
| GLM 4.7 | 15. marts 2026 |
| GLM 4.6 | 15. marts 2026 |
---
## Privatliv
Alle vores modeller er hostet i USA. Vores udbydere følger en nul-opbevaringspolitik og bruger ikke dine data til modeltræning, med følgende undtagelser:
+17 -2
View File
@@ -57,6 +57,7 @@ Du kannst unsere Modelle auch ueber die folgenden API-Endpunkte aufrufen.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -114,12 +115,12 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -140,6 +141,7 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -184,6 +186,19 @@ Mit aktiviertem Auto-Reload kann die Abrechnung dennoch darueber liegen, falls d
---
### Veraltete Modelle
| Model | Datum der Abschaltung |
| ---------------- | --------------------- |
| Qwen3 Coder 480B | 6. Feb. 2026 |
| Kimi K2 Thinking | 6. Maerz 2026 |
| Kimi K2 | 6. Maerz 2026 |
| MiniMax M2.1 | 15. Maerz 2026 |
| GLM 4.7 | 15. Maerz 2026 |
| GLM 4.6 | 15. Maerz 2026 |
---
## Datenschutz
Alle Modelle werden in den USA gehostet.
+15
View File
@@ -62,6 +62,7 @@ También puede acceder a nuestros modelos a través de los siguientes puntos fin
| Modelo | Model ID | Endpoint | AI SDK package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -145,6 +146,7 @@ Apoyamos un modelo de pago por uso. A continuación se muestran los precios **po
| 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.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14.00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14.00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14.00 | $0,175 | - |
@@ -190,6 +192,19 @@ cobrarle más de $20 si su saldo es inferior a $5.
---
### Modelos obsoletos
| Modelo | Fecha de retiro |
| ---------------- | ------------------- |
| Qwen3 Coder 480B | 6 de feb. de 2026 |
| Kimi K2 Thinking | 6 de marzo de 2026 |
| Kimi K2 | 6 de marzo de 2026 |
| MiniMax M2.1 | 15 de marzo de 2026 |
| GLM 4.7 | 15 de marzo de 2026 |
| GLM 4.6 | 15 de marzo de 2026 |
---
## Privacidad
Todos nuestros modelos están alojados en los EE. UU. Nuestros proveedores siguen una política de retención cero y no utilizan sus datos para la capacitación de modelos, con las siguientes excepciones:
+15
View File
@@ -55,6 +55,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| Modèle | ID du modèle | Point de terminaison | Package SDK IA |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Nous soutenons un modèle de paiement à l'utilisation. Vous trouverez ci-dessou
| Gemini 3 Pro (≤ 200K jetons) | 2,00 $ | 12,00 $ | 0,20 $ | - |
| Gemini 3 Pro (> 200K jetons) | 4,00 $ | 18,00 $ | 0,40 $ | - |
| Gemini 3 Flash | 0,50 $ | 3,00 $ | 0,05 $ | - |
| GPT 5.4 | 2,50 $ | 15,00 $ | 0,25 $ | - |
| GPT 5.3 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
| GPT 5.2 | 1,75 $ | 14,00 $ | 0,175 $ | - |
| GPT 5.2 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
@@ -178,6 +180,19 @@ Par exemple, disons que vous définissez une limite d'utilisation mensuelle à 2
---
### Modèles obsolètes
| Modèle | Date de dépréciation |
| ---------------- | -------------------- |
| Qwen3 Coder 480B | 6 février 2026 |
| Kimi K2 Thinking | 6 mars 2026 |
| Kimi K2 | 6 mars 2026 |
| MiniMax M2.1 | 15 mars 2026 |
| GLM 4.7 | 15 mars 2026 |
| GLM 4.6 | 15 mars 2026 |
---
## Confidentialité
Tous nos modèles sont hébergés aux États-Unis. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour la formation de modèles, avec les exceptions suivantes :
+15
View File
@@ -55,6 +55,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| Modello | ID modello | Endpoint | Pacchetto AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Per esempio, se imposti un limite mensile a $20, Zen non usera piu di $20 in un
---
### Modelli deprecati
| Modello | Data di deprecazione |
| ---------------- | -------------------- |
| Qwen3 Coder 480B | 6 feb 2026 |
| Kimi K2 Thinking | 6 mar 2026 |
| Kimi K2 | 6 mar 2026 |
| MiniMax M2.1 | 15 mar 2026 |
| GLM 4.7 | 15 mar 2026 |
| GLM 4.6 | 15 mar 2026 |
---
## Privacy
Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per training dei modelli, con le seguenti eccezioni:
+15
View File
@@ -54,6 +54,7 @@ OpenCode Zen は、OpenCode の他のプロバイダーと同様に機能しま
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -179,6 +181,19 @@ https://opencode.ai/zen/v1/models
---
### 非推奨モデル
| Model | Deprecation date |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | 2026年2月6日 |
| Kimi K2 Thinking | 2026年3月6日 |
| Kimi K2 | 2026年3月6日 |
| MiniMax M2.1 | 2026年3月15日 |
| GLM 4.7 | 2026年3月15日 |
| GLM 4.6 | 2026年3月15日 |
---
## プライバシー
すべてのモデルは米国でホストされています。当社のプロバイダーはゼロ保持ポリシーに従い、次の例外を除いて、モデルのトレーニングにデータを使用しません。
+17 -2
View File
@@ -55,6 +55,7 @@ OpenCode Zen은 OpenCode의 다른 제공자와 동일한 방식으로 작동합
| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -111,12 +112,12 @@ https://opencode.ai/zen/v1/models
| --------------------------------- | ------ | ------ | --------- | --------- |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -180,6 +182,19 @@ https://opencode.ai/zen/v1/models
---
### 지원 중단 모델
| 모델 | 지원 중단일 |
| ---------------- | --------------- |
| Qwen3 Coder 480B | 2026년 2월 6일 |
| Kimi K2 Thinking | 2026년 3월 6일 |
| Kimi K2 | 2026년 3월 6일 |
| MiniMax M2.1 | 2026년 3월 15일 |
| GLM 4.7 | 2026년 3월 15일 |
| GLM 4.6 | 2026년 3월 15일 |
---
## 개인정보 보호
당사의 모든 모델은 미국에서 호스팅됩니다. 당사 제공자는 데이터 무보존(zero-retention) 정책을 따르며, 아래의 예외를 제외하고는 귀하의 데이터를 모델 학습에 사용하지 않습니다.
+16 -1
View File
@@ -64,6 +64,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| Modell | Modell ID | Endepunkt | AI SDK Pakke |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -121,7 +122,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
| --------------------------------- | ------- | ------ | ------------- | --------------- |
| Big Pickle | Gratis | Gratis | Gratis | - |
| MiniMax M2.5 Free | Gratis | Gratis | Gratis | - |
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | - |
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | $0,375 |
| 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 | - |
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
| 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.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
@@ -192,6 +194,19 @@ belaster deg mer enn $20 hvis saldoen din går under $5.
---
### Utfasede modeller
| Modell | Utfasingdato |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6. feb. 2026 |
| Kimi K2 Thinking | 6. mars 2026 |
| Kimi K2 | 6. mars 2026 |
| MiniMax M2.1 | 15. mars 2026 |
| GLM 4.7 | 15. mars 2026 |
| GLM 4.6 | 15. mars 2026 |
---
## Personvern
Alle våre modeller er hostet i USA. Leverandørene våre følger retningslinjer om ingen datalagring og bruker ikke dataene dine til modellopplæring, med følgende unntak:
+57 -42
View File
@@ -1,21 +1,21 @@
---
title: Zen
description: Wyselekcjonowana lista modeli dostarczonych przez opencode.
description: Wyselekcjonowana lista modeli dostarczonych przez OpenCode.
---
import config from "../../../../config.mjs"
export const console = config.console
export const email = `mailto:${config.email}`
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół opencode.
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół OpenCode.
:::note
OpenCode Zen is currently in beta.
OpenCode Zen jest obecnie w wersji beta.
:::
Zen działa jak każdy inny dostawca opencode. Logujesz się do OpenCode Zen i dostajesz
Twój klucz API. Jest **całkowicie opcjonalny** i nie musisz go używać, aby z niego korzystać
opencode.
Zen działa jak każdy inny dostawca w OpenCode. Logujesz się do OpenCode Zen i otrzymujesz
swój klucz API. Jest to **całkowicie opcjonalne** i nie musisz tego używać, aby korzystać z
OpenCode.
---
@@ -23,23 +23,23 @@ opencode.
Istnieje ogromna liczba modeli, ale tylko kilka z nich
działa dobrze jako agenci kodujący. Dodatkowo większość dostawców jest
skonfigurowana bardzo różnie; więc otrzymujesz zupełnie inną wydajność i jakość.
skonfigurowana bardzo różnie, więc otrzymujesz bardzo różną wydajność i jakość.
:::tip
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z opencode.
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z OpenCode.
:::
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie będzie to możliwe
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie możesz być
pewien, czy otrzymujesz najlepszą wersję modelu, jaki chcesz.
Aby to naprawić, zrobiliśmy kilka rzeczy:
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak to zrobić
najlepiej je uruchom.
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak
najlepiej je uruchamiać.
2. Następnie współpracowaliśmy z kilkoma dostawcami, aby upewnić się, że są one obsługiwane
correctly.
3. Na koniec porównaliśmy kombinację modelu/dostawcy i otrzymaliśmy wynik
z listą, którą z przyjemnością polecamy.
poprawnie.
3. Na koniec sprawdziliśmy wydajność kombinacji modelu/dostawcy i stworzyliśmy
listę, którą z czystym sumieniem polecamy.
OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
@@ -47,14 +47,14 @@ OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
## Jak to działa
OpenCode Zen działa jak każdy inny dostawca opencode.
OpenCode Zen działa jak każdy inny dostawca w OpenCode.
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz swoje rozliczenia
szczegóły i skopiuj klucz API.
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz dane rozliczeniowe
i kopiujesz swój klucz API.
2. Uruchamiasz polecenie `/connect` w TUI, wybierasz OpenCode Zen i wklejasz klucz API.
3. Uruchom `/models` w TUI, aby zobaczyć listę zalecanych przez nas modeli.
Opłata jest pobierana za każde żądanie i możesz dodać kredyty do swojego konta.
Opłata jest pobierana za każde żądanie i możesz dodać środki do swojego konta.
---
@@ -64,6 +64,7 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
| Model | Identyfikator modelu | Punkt końcowy | Pakiet SDK AI |
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -97,9 +98,9 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
[Identyfikator modelu](/docs/config/#models) w konfiguracji opencode
używa formatu `opencode/<model-id>`. Na przykład w przypadku Kodeksu GPT 5.2 zrobiłbyś to
użyj `opencode/gpt-5.2-codex` w swojej konfiguracji.
[Identyfikator modelu](/docs/config/#models) w konfiguracji OpenCode
używa formatu `opencode/<model-id>`. Na przykład w przypadku GPT 5.2 Codex użyłbyś
`opencode/gpt-5.2-codex` w swojej konfiguracji.
---
@@ -121,12 +122,12 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| --------------------------------- | ------- | ------- | --------------------------- | -------------------------- |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -147,6 +148,7 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -158,10 +160,10 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. To jest [model niskokosztowy](/docs/config/#models), który służy do generowania tytułów sesji.
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. Jest to [tani model](/docs/config/#models), który jest używany do generowania tytułów Twoich sesji.
:::note
Opłaty za karty kredytowe są przenoszone na koszt (4,4% + 0,30 USD za transakcję); nie pobieramy żadnych dodatkowych opłat.
Opłaty za karty kredytowe są przenoszone po kosztach (4,4% + 0,30 USD za transakcję); nie pobieramy nic poza tym.
:::
Darmowe modele:
@@ -177,18 +179,31 @@ Darmowe modele:
Jeśli Twoje saldo spadnie poniżej 5 USD, Zen automatycznie doładuje 20 USD.
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne przeładowywanie.
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne doładowanie.
---
### Limity miesięczne
Możesz także ustawić miesięczny limit wykorzystania dla całego obszaru roboczego i dla każdego z nich
członek Twojego zespołu.
Możesz także ustawić miesięczny limit użytkowania dla całego obszaru roboczego i dla każdego
członka Twojego zespołu.
Załóżmy na przykład, że ustawiłeś miesięczny limit użytkowania na 20 USD, Zen nie będzie z niego korzystał
ponad 20 dolarów miesięcznie. Ale jeśli masz włączone automatyczne przeładowywanie, Zen może się skończyć
obciąży Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
Na przykład, jeśli ustawisz miesięczny limit użytkowania na 20 USD, Zen nie zużyje
więcej niż 20 dolarów w miesiącu. Ale jeśli masz włączone automatyczne doładowanie, Zen może
obciążyć Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
---
### Przestarzałe modele
| Model | Data wycofania |
| ---------------- | -------------- |
| Qwen3 Coder 480B | 6 lutego 2026 |
| Kimi K2 Thinking | 6 marca 2026 |
| Kimi K2 | 6 marca 2026 |
| MiniMax M2.1 | 15 marca 2026 |
| GLM 4.7 | 15 marca 2026 |
| GLM 4.6 | 15 marca 2026 |
---
@@ -198,22 +213,22 @@ Wszystkie nasze modele są hostowane w USA. Nasi dostawcy przestrzegają polityk
- Big Pickle: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
- MiniMax M2.5 Free: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
- Interfejsy API OpenAI: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
- Interfejsy API Anthropic: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych firmy Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
- API OpenAI: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
- API Anthropic: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
---
## Dla zespołów
Zen świetnie sprawdza się także w zespołach. Możesz zapraszać członków zespołu, przypisywać role, zarządz
Zen działa świetnie także dla zespołów. Możesz zapraszać członków zespołu, przypisywać role, dobier
modele, z których korzysta Twój zespół i nie tylko.
:::note
Obszary robocze są obecnie bezpłatne dla zespołów w ramach wersji beta.
:::
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta. Będziemy
wkrótce udostępnimy więcej szczegółów na temat cen.
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta.
Wkrótce udostępnimy więcej szczegółów na temat cen.
---
@@ -221,8 +236,8 @@ wkrótce udostępnimy więcej szczegółów na temat cen.
Możesz zapraszać członków zespołu do swojego obszaru roboczego i przypisywać role:
- **Administrator**: Zarządzaj modelami, członkami, kluczami API i rozliczeniami
- **Członek**: Zarządzaj tylko własnymi kluczami API
- **Admin**: Zarządzanie modelami, członkami, kluczami API i rozliczeniami
- **Członek**: Zarządzanie tylko własnymi kluczami API
Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego członka, aby utrzymać koszty pod kontrolą.
@@ -233,7 +248,7 @@ Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego
Administratorzy mogą włączać i wyłączać określone modele w obszarze roboczym. Żądania skierowane do wyłączonego modelu zwrócą błąd.
Jest to przydatne w przypadkach, gdy chcesz wyłączyć korzystanie z modelu, który
collects data.
zbiera dane.
---
@@ -253,6 +268,6 @@ i chcesz go używać zamiast tego, który zapewnia Zen.
Stworzyliśmy OpenCode Zen, aby:
1. **Testować** (Benchmark) najlepsze modele/dostawców dla agentów kodujących.
2. Miej dostęp do opcji **najwyższej jakości**, a nie obniżaj wydajności ani nie kieruj się do tańszych dostawców.
3. Przekaż wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
4. Nie **nie blokuj**, umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalaj na korzystanie z opencode dowolnego innego dostawcy.
2. Mieć dostęp do opcji **najwyższej jakości**, a nie obniżać wydajności ani nie kierować do tańszych dostawców.
3. Przekazywać wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
4. Nie **mieć blokady** (no lock-in), umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalać na korzystanie z dowolnego innego dostawcy w OpenCode.
@@ -55,6 +55,7 @@ Você também pode acessar nossos modelos através dos seguintes endpoints da AP
| Modelo | ID do Modelo | Endpoint | Pacote AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Nós suportamos um modelo de pagamento conforme o uso. Abaixo estão os preços
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Por exemplo, digamos que você defina um limite de uso mensal de $20, o Zen não
---
### Modelos obsoletos
| Modelo | Data de descontinuação |
| ---------------- | ---------------------- |
| Qwen3 Coder 480B | 6 de fev. de 2026 |
| Kimi K2 Thinking | 6 de mar. de 2026 |
| Kimi K2 | 6 de mar. de 2026 |
| MiniMax M2.1 | 15 de mar. de 2026 |
| GLM 4.7 | 15 de mar. de 2026 |
| GLM 4.6 | 15 de mar. de 2026 |
---
## Privacidade
Todos os nossos modelos estão hospedados nos EUA. Nossos provedores seguem uma política de zero retenção e não usam seus dados para treinamento de modelos, com as seguintes exceções:
+15
View File
@@ -63,6 +63,7 @@ OpenCode Zen работает так же, как и любой другой п
| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK |
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -146,6 +147,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200 тыс. токенов) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200 тыс. токенов) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -191,6 +193,19 @@ https://opencode.ai/zen/v1/models
---
### Устаревшие модели
| Модель | Дата отключения |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | 6 февр. 2026 г. |
| Kimi K2 Thinking | 6 марта 2026 г. |
| Kimi K2 | 6 марта 2026 г. |
| MiniMax M2.1 | 15 марта 2026 г. |
| GLM 4.7 | 15 марта 2026 г. |
| GLM 4.6 | 15 марта 2026 г. |
---
## Конфиденциальность
Все наши модели размещены в США. Наши поставщики придерживаются политики нулевого хранения и не используют ваши данные для обучения моделей, за следующими исключениями:
+18 -3
View File
@@ -64,6 +64,7 @@ OpenCode Zen ทำงานเหมือนกับผู้ให้บร
| Model | Model ID | Endpoint | แพ็คเกจ AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -121,12 +122,12 @@ https://opencode.ai/zen/v1/models
| --------------------------------- | ---------- | -------- | ------- | ---------- |
| Big Pickle | ฟรี | ฟรี | ฟรี | - |
| MiniMax M2.5 Free | ฟรี | ฟรี | ฟรี | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -147,6 +148,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -192,11 +194,24 @@ https://opencode.ai/zen/v1/models
---
### โมเดลที่เลิกใช้แล้ว
| Model | วันที่เลิกใช้ |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6 ก.พ. 2026 |
| Kimi K2 Thinking | 6 มี.ค. 2026 |
| Kimi K2 | 6 มี.ค. 2026 |
| MiniMax M2.1 | 15 มี.ค. 2026 |
| GLM 4.7 | 15 มี.ค. 2026 |
| GLM 4.6 | 15 มี.ค. 2026 |
---
## ความเป็นส่วนตัว
โมเดลทั้งหมดของเราโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการของเราปฏิบัติตามนโยบายการเก็บรักษาเป็นศูนย์ และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมีข้อยกเว้นต่อไปนี้:
- Big Pickle: ในช่วงระยะเวลาว่าง ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
- Big Pickle: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
- MiniMax M2.5 Free: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดล
- OpenAI API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ OpenAI](https://platform.openai.com/docs/guides/your-data)
- Anthropic API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage)
+15
View File
@@ -55,6 +55,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1 milyon token başına*
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Ayrıca tüm çalışma alanı ve ekibinizin her üyesi için aylık kullanım l
---
### Kullanımdan kaldırılan modeller
| Model | Kullanımdan kaldırılma tarihi |
| ---------------- | ----------------------------- |
| Qwen3 Coder 480B | 6 Şub 2026 |
| Kimi K2 Thinking | 6 Mar 2026 |
| Kimi K2 | 6 Mar 2026 |
| MiniMax M2.1 | 15 Mar 2026 |
| GLM 4.7 | 15 Mar 2026 |
| GLM 4.6 | 15 Mar 2026 |
---
## Gizlilik
Tüm modellerimiz ABD'de barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikasını izler ve aşağıdaki istisnalar dışında verilerinizi model eğitimi için kullanmaz:
+82 -76
View File
@@ -62,44 +62,47 @@ You are charged per request and you can add credits to your account.
You can also access our models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| 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` |
| 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 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` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------- | ------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| 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` |
| 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 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` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
The [model id](/docs/config/#models) in your OpenCode config
uses the format `opencode/<model-id>`. For example, for GPT 5.2 Codex, you would
use `opencode/gpt-5.2-codex` in your config.
uses the format `opencode/<model-id>`. For example, for GPT 5.3 Codex, you would
use `opencode/gpt-5.3-codex` in your config.
---
@@ -117,46 +120,49 @@ https://opencode.ai/zen/v1/models
We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.10 | - |
| 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.3 Codex | $1.75 | $14.00 | $0.175 | - |
| 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 | Free | Free | Free | - |
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| 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 | $0.60 | $3.00 | $0.10 | - |
| 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.4 Pro | $30.00 | $180.00 | $30.00 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| 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 | Free | Free | Free | - |
You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions.
@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| 模型 | 模型 ID | 端点 | AI SDK 包 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
---
### 已弃用模型
| 模型 | 弃用日期 |
| ---------------- | ------------------ |
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
| Kimi K2 | 2026 年 3 月 6 日 |
| MiniMax M2.1 | 2026 年 3 月 15 日 |
| GLM 4.7 | 2026 年 3 月 15 日 |
| GLM 4.6 | 2026 年 3 月 15 日 |
---
## 隐私
我们所有的模型都托管在美国。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外:
@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式與 OpenCode 中的任何其他供應商相同。
| 模型 | 模型 ID | 端點 | AI SDK 套件 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
| 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.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
---
### 已棄用的模型
| 模型 | 棄用日期 |
| ---------------- | ------------------ |
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
| Kimi K2 | 2026 年 3 月 6 日 |
| MiniMax M2.1 | 2026 年 3 月 15 日 |
| GLM 4.7 | 2026 年 3 月 15 日 |
| GLM 4.6 | 2026 年 3 月 15 日 |
---
## 隱私
我們所有的模型都託管在美國。我們的供應商遵循零保留政策,不會將你的資料用於模型訓練,但以下情況除外:
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.18",
"version": "1.2.19",
"publisher": "sst-dev",
"repository": {
"type": "git",