Compare commits

...

38 Commits

Author SHA1 Message Date
Ryan Vogel 8e726761e2 fix(session): include image source paths in model context 2026-05-19 14:32:27 +00:00
Shawn 2932a7a35d fix(app): invalidate provider queries after config update to show custom providers immediately (#28313) 2026-05-19 20:31:36 +08:00
Brendan Allan 922b2e10eb refactor(app): remove Accessor wrapping from timeline row renders (#28334) 2026-05-19 20:22:50 +08:00
opencode-agent[bot] a2ee437a0e chore: generate 2026-05-19 12:21:56 +00:00
Kit Langton 6618e2bce2 feat(native-llm): route Anthropic API-key models through native runtime (#28271) 2026-05-19 08:20:27 -04:00
Kit Langton cb15b3ad84 test(cli): subprocess integration tests for opencode acp (#28265) 2026-05-19 08:18:22 -04:00
Sebastian ee16f08ffa Fix legacy pgup/pgdown TUI keybind aliases (#28275) 2026-05-19 13:52:00 +02:00
Dustin Deus 9790a61f96 chore(docs): remove beta zen go (#28317)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-05-19 12:08:40 +02:00
Dustin Deus 2339aacbf2 chore(team): add starptech to the team members list (#28320) 2026-05-19 10:49:10 +02:00
opencode-agent[bot] 2d348da504 chore: generate 2026-05-19 06:47:00 +00:00
MYMDO cbd2620c46 feat(i18n): add Ukrainian (uk) locale support (#28061) 2026-05-19 14:45:39 +08:00
opencode-agent[bot] 3bd304796b chore: generate 2026-05-19 06:13:16 +00:00
Shoubhit Dash dac81cdb68 fix(httpapi): preserve v2 openapi errors (#28298) 2026-05-19 11:42:01 +05:30
Shoubhit Dash 2c3bcf3e41 feat(httpapi): add v2 public error schemas (#28297) 2026-05-19 11:26:56 +05:30
Kit Langton d9d43d8519 test: migrate plugin config fixtures 2026-05-18 22:14:01 -04:00
Kit Langton bcc69f0afb test: migrate managed config fixtures 2026-05-18 22:02:59 -04:00
Kit Langton e53563f402 test: migrate remaining simple config fixtures 2026-05-18 21:42:00 -04:00
Kit Langton 7e4b02f1bf test: migrate config legacy tool fixtures 2026-05-18 21:27:43 -04:00
Kit Langton 996928b32a Migrate config .opencode file tests to instance fixtures (#28268) 2026-05-18 21:17:11 -04:00
Kit Langton c032e821bc Migrate config update tests to instance fixtures (#28266) 2026-05-18 20:57:42 -04:00
opencode-agent[bot] 6f160bb48f chore: generate 2026-05-19 00:57:09 +00:00
Kit Langton ebb672ac39 test(cli): subprocess integration tests for opencode serve (#28263) 2026-05-19 00:55:58 +00:00
Kit Langton 9a19e84265 Migrate config agent tests to instance fixtures (#28213) 2026-05-18 23:55:33 +00:00
Kit Langton 338666d13e Migrate config template tests to instance fixtures (#28211) 2026-05-18 23:37:58 +00:00
Kit Langton 7b8a1037a0 refactor(test/lib): generalize run-process harness into cli-process (#28253) 2026-05-18 19:16:10 -04:00
Kit Langton ee5cf45ef9 Migrate simple config tests to instance fixtures (#28210) 2026-05-18 19:13:16 -04:00
opencode-agent[bot] 2e1593dea5 chore: generate 2026-05-18 22:33:32 +00:00
Kit Langton 0f3d168fdd test(cli): subprocess integration test harness + regression suite for opencode run (#28230) 2026-05-18 18:32:20 -04:00
opencode-agent[bot] ce09fc8356 chore: generate 2026-05-18 22:03:00 +00:00
Luke Parker 44a35c5895 test(app): add session timeline smoke coverage (#26619) 2026-05-18 22:01:29 +00:00
Kit Langton 8a321c4536 fix(native-llm): prefer console opencode token (#28237) 2026-05-18 17:23:57 -04:00
opencode ef9e567e5f sync release versions for v1.15.5 2026-05-18 20:57:13 +00:00
Kit Langton b396b71c6f fix(ui): guard reasoning renderer against undefined text (#28222) 2026-05-18 16:04:07 -04:00
Aiden Cline d8efc575fa refactor(session): extract prompt tool resolution (#28204) 2026-05-18 14:50:31 -05:00
opencode-agent[bot] e1fbed8fb6 chore: update nix node_modules hashes 2026-05-18 19:12:18 +00:00
James Long 12ae22378f fix(plugin): ask in tools from plugins returns promise instead of effect (#28217) 2026-05-18 18:57:29 +00:00
opencode-agent[bot] a88b436eec chore: generate 2026-05-18 18:42:55 +00:00
Kit Langton dbe36851bc Preview native LLM runtime stack (#27114) 2026-05-18 14:41:36 -04:00
117 changed files with 7761 additions and 1972 deletions
+1
View File
@@ -14,3 +14,4 @@ rekram1-node
thdxr thdxr
simonklee simonklee
vimtor vimtor
starptech
+19 -17
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.15.4", "version": "1.15.5",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,7 +253,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -307,7 +307,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -366,7 +366,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.15.4", "version": "1.15.5",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -421,6 +421,7 @@
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -489,6 +490,7 @@
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1",
@@ -520,7 +522,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -558,7 +560,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -573,7 +575,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -608,7 +610,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -657,7 +659,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-qAkjcbc1nJqOnCrNQ0bnsM4WG2ii5K1JWS9ohAYdjus=", "x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
"aarch64-linux": "sha256-Nb+F0e3CvQv+uLzHzj9JKp5hV78mCnlSqFXzgIvgR24=", "aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
"aarch64-darwin": "sha256-BIXALWWrjEZLUKZrY6l6+scjZmKFscFxX26TvWOvXGQ=", "aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
"x86_64-darwin": "sha256-3uaFXl/n6je7AzIfsY1pvt3Ln/U1Oshx3z7ohuVPEs8=" "x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
} }
} }
@@ -0,0 +1,314 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
return {
file,
additions: (seed % 9) + 1,
deletions: seed % 4,
before: code(seed, 32),
after: code(seed + 1, 38),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
)
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -0,0 +1,412 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
const forbiddenText = ["Load details", "Show earlier steps"]
type SmokeState = {
ids: string[]
visibleIds: string[]
messageIds: string[]
visibleMessageIds: string[]
topVisibleId?: string
signature: string
scrollTop: number
scrollHeight: number
clientHeight: number
errorToasts: string[]
forbiddenText: string[]
}
type SmokeWindow = Window & {
__timelineSmokeState?: () => SmokeState
__timelineSmokeErrorToasts?: string[]
__timelineSmokeForbiddenText?: string[]
}
test.describe("smoke: session timeline", () => {
test.setTimeout(240_000)
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page)
await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
})
})
async function configureSmokePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = []
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
const idsOf = (el: HTMLElement) =>
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
smoke.__timelineSmokeState = () => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
return {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
const ids: string[] = []
const visibleIds: string[] = []
const scrollerRect = scroller.getBoundingClientRect()
let topVisibleId: string | undefined
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
const next = idsOf(el)
ids.push(...next)
const rect = el.getBoundingClientRect()
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
if (!topVisibleId) topVisibleId = next[0]
visibleIds.push(...next)
}
}
const messageIds: string[] = []
const visibleMessageIds: string[] = []
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
const rect = el.getBoundingClientRect()
const id = el.dataset.messageId
if (id) {
messageIds.push(id)
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
}
return {
id,
top: Math.round(rect.top),
bottom: Math.round(rect.bottom),
}
})
const signature = JSON.stringify({
top: Math.round(scroller.scrollTop),
height: Math.round(scroller.scrollHeight),
rows,
ids,
})
return {
ids,
visibleIds,
messageIds,
visibleMessageIds,
topVisibleId,
signature,
scrollTop: Math.round(scroller.scrollTop),
scrollHeight: Math.round(scroller.scrollHeight),
clientHeight: Math.round(scroller.clientHeight),
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
let recordFrame: number | undefined
const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
}
const text = document.body?.textContent ?? ""
for (const value of ["Load details", "Show earlier steps"]) {
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
smoke.__timelineSmokeForbiddenText!.push(value)
}
}
}
const start = () => {
const root = document.documentElement ?? document.body
if (!root) return
new MutationObserver(() => {
if (recordFrame) return
recordFrame = requestAnimationFrame(() => {
recordFrame = undefined
record()
})
}).observe(root, { childList: true, subtree: true })
record()
}
if (document.documentElement ?? document.body) start()
else document.addEventListener("DOMContentLoaded", start, { once: true })
})
}
async function expectCanScrollToStart(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await pointAtTimeline(page)
const seenParts = new Set<string>()
const seenMessages = new Set<string>()
const samples: TraversalSample[] = []
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
if (
current.scrollTop <= 1 &&
seenParts.size === expectedPartIDs.length &&
seenMessages.size === expectedMessageIDs.length
) {
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
return
}
const before = current
const changed = await scrollTimelineUp(page, current)
current = await timelineState(page)
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
else unchangedAtTop = 0
if (unchangedAtTop >= 2) break
}
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
}
async function timelineState(page: Page) {
return page.evaluate(
() =>
(window as SmokeWindow).__timelineSmokeState?.() ?? {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: [],
forbiddenText: [],
},
)
}
function timelineScroller(page: Page) {
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
}
async function pointAtTimeline(page: Page) {
const box = await timelineScroller(page).boundingBox()
if (!box) throw new Error("Timeline scroller is not visible")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
async function scrollTimelineUp(page: Page, before: SmokeState) {
return page.evaluate(
(prev) =>
new Promise<boolean>((resolve) => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
resolve(false)
return
}
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
let frames = 0
let stableFrames = 0
let last = ""
let changed = false
const check = () => {
const current = read()
if (current !== prev) changed = true
if (current === last) stableFrames++
else {
stableFrames = 0
last = current
}
if (changed && stableFrames >= 2) {
resolve(true)
return
}
frames++
if (frames >= 30) {
resolve(changed)
return
}
requestAnimationFrame(check)
}
requestAnimationFrame(check)
}),
before.signature,
)
}
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
const actualSet = new Set(actual)
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
}
function unique(values: string[]) {
return values.filter((value, index) => values.indexOf(value) === index)
}
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
for (const id of state.ids) seenParts.add(id)
for (const id of state.visibleIds) seenParts.add(id)
for (const id of state.messageIds) seenMessages.add(id)
for (const id of state.visibleMessageIds) seenMessages.add(id)
}
type TraversalSample = ReturnType<typeof sampleTraversal>
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
return {
seenParts,
seenMessages,
mounted: state.ids.length,
visible: state.visibleIds.length,
mountedMessages: unique(state.messageIds).length,
visibleMessages: unique(state.visibleMessageIds).length,
top: state.scrollTop,
height: state.scrollHeight,
first: state.ids[0],
last: state.ids.at(-1),
topVisible: state.topVisibleId,
visibleFirst: state.visibleIds[0],
visibleLast: state.visibleIds.at(-1),
}
}
function sampleSummary(samples: TraversalSample[]) {
return samples
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
.map(
(sample, index) =>
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
)
.join("\n")
}
async function waitForTimelineStable(page: Page) {
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
requestAnimationFrame(() => {
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() => {
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() =>
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
)
})
})
}),
)
}
async function expectSessionTimelineReady(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
}
function expectCompleteScroll(
state: SmokeState,
expectedPartIDs: string[],
expectedMessageIDs: string[],
seenParts: Set<string>,
seenMessages: Set<string>,
samples: TraversalSample[],
) {
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
expect(
expectedPartIDs.filter((id) => !seenParts.has(id)),
`missing visible timeline parts\n${sampleSummary(samples)}`,
).toEqual([])
expect(
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
`missing visible messages\n${sampleSummary(samples)}`,
).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331)
}
async function openProject(page: Page, projectName: string) {
await page.goto("/")
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
}
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first()
.evaluate((el) => (el as HTMLElement).click())
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function expectSessionReady(page: Page, projectName: string) {
await expect(page.getByText(projectName).first()).toBeVisible()
await expect(page.getByText("Ask anything...")).toBeVisible()
}
-11
View File
@@ -1,11 +0,0 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+18
View File
@@ -0,0 +1,18 @@
import { expect, type Page } from "@playwright/test"
export function trackPageErrors(page: Page) {
const errors: string[] = []
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
return errors
}
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
consoleErrors: [],
toastErrors: [],
forbiddenText: [],
})
}
+86
View File
@@ -0,0 +1,86 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig {
provider: unknown
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
},
"/project": [config.project],
"/project/current": config.project,
"/agent": [{ name: "build", mode: "primary" }],
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
const pageData = config.pageMessages(messagesMatch[1], limit, before)
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
return json(route, {})
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.4", "version": "1.15.5",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+7 -1
View File
@@ -422,7 +422,13 @@ function createGlobalSync() {
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
onSuccess: () => bootstrap.refetch(), onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
},
})) }))
return { return {
+6
View File
@@ -18,6 +18,7 @@ export type Locale =
| "ja" | "ja"
| "pl" | "pl"
| "ru" | "ru"
| "uk"
| "ar" | "ar"
| "no" | "no"
| "br" | "br"
@@ -45,6 +46,7 @@ const LOCALES: readonly Locale[] = [
"ja", "ja",
"pl", "pl",
"ru", "ru",
"uk",
"bs", "bs",
"ar", "ar",
"no", "no",
@@ -65,6 +67,7 @@ const INTL: Record<Locale, string> = {
ja: "ja", ja: "ja",
pl: "pl", pl: "pl",
ru: "ru", ru: "ru",
uk: "uk",
ar: "ar", ar: "ar",
no: "nb-NO", no: "nb-NO",
br: "pt-BR", br: "pt-BR",
@@ -85,6 +88,7 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
ja: "language.ja", ja: "language.ja",
pl: "language.pl", pl: "language.pl",
ru: "language.ru", ru: "language.ru",
uk: "language.uk",
ar: "language.ar", ar: "language.ar",
no: "language.no", no: "language.no",
br: "language.br", br: "language.br",
@@ -110,6 +114,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
@@ -145,6 +150,7 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "ja", match: (language) => language.startsWith("ja") }, { locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") }, { locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") }, { locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") }, { locale: "ar", match: (language) => language.startsWith("ar") },
{ {
locale: "no", locale: "no",
+1
View File
@@ -416,6 +416,7 @@ export const dict = {
"language.no": "Norsk", "language.no": "Norsk",
"language.br": "Português (Brasil)", "language.br": "Português (Brasil)",
"language.bs": "Bosanski", "language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย", "language.th": "ไทย",
"language.tr": "Türkçe", "language.tr": "Türkçe",
+2 -1
View File
@@ -12,12 +12,13 @@ import { dict as ko } from "./ko"
import { dict as no } from "./no" import { dict as no } from "./no"
import { dict as pl } from "./pl" import { dict as pl } from "./pl"
import { dict as ru } from "./ru" import { dict as ru } from "./ru"
import { dict as uk } from "./uk"
import { dict as th } from "./th" import { dict as th } from "./th"
import { dict as zh } from "./zh" import { dict as zh } from "./zh"
import { dict as zht } from "./zht" import { dict as zht } from "./zht"
import { dict as tr } from "./tr" import { dict as tr } from "./tr"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht] const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht]
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
describe("i18n parity", () => { describe("i18n parity", () => {
+970
View File
@@ -0,0 +1,970 @@
export const dict = {
"command.category.suggested": "Рекомендовані",
"command.category.view": "Вигляд",
"command.category.project": "Проєкт",
"command.category.provider": "Провайдер",
"command.category.server": "Сервер",
"command.category.session": "Сесія",
"command.category.theme": "Тема",
"command.category.language": "Мова",
"command.category.file": "Файл",
"command.category.context": "Контекст",
"command.category.terminal": "Термінал",
"command.category.model": "Модель",
"command.category.mcp": "MCP",
"command.category.agent": "Агент",
"command.category.permissions": "Дозволи",
"command.category.workspace": "Робоча область",
"command.category.settings": "Налаштування",
"theme.scheme.system": "Системна",
"theme.scheme.light": "Світла",
"theme.scheme.dark": "Темна",
"command.sidebar.toggle": "Перемкнути бічну панель",
"command.project.open": "Відкрити проєкт",
"command.project.previous": "Попередній проєкт",
"command.project.next": "Наступний проєкт",
"command.project.index": "Перемкнути на проєкт {{index}}",
"command.provider.connect": "Підключити провайдера",
"command.server.switch": "Перемкнути сервер",
"command.settings.open": "Відкрити налаштування",
"command.session.previous": "Попередня сесія",
"command.session.next": "Наступна сесія",
"command.session.previous.unseen": "Попередня непрочитана сесія",
"command.session.next.unseen": "Наступна непрочитана сесія",
"command.session.archive": "Архівувати сесію",
"command.palette": "Палітра команд",
"command.theme.cycle": "Перемкнути тему",
"command.theme.set": "Використати тему: {{theme}}",
"command.theme.scheme.cycle": "Перемкнути кольорову схему",
"command.theme.scheme.set": "Використати кольорову схему: {{scheme}}",
"command.language.cycle": "Перемкнути мову",
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.context.addSelection": "Додати виділення до контексту",
"command.context.addSelection.description": "Додати вибрані рядки з поточного файлу",
"command.input.focus": "Фокус на полі введення",
"command.terminal.toggle": "Перемкнути термінал",
"command.fileTree.toggle": "Перемкнути дерево файлів",
"command.review.toggle": "Перемкнути огляд",
"command.terminal.new": "Новий термінал",
"command.terminal.new.description": "Створити нову вкладку термінала",
"command.steps.toggle": "Перемкнути кроки",
"command.steps.toggle.description": "Показати або приховати кроки для поточного повідомлення",
"command.message.previous": "Попереднє повідомлення",
"command.message.previous.description": "Перейти до попереднього повідомлення користувача",
"command.message.next": "Наступне повідомлення",
"command.message.next.description": "Перейти до наступного повідомлення користувача",
"command.model.choose": "Вибрати модель",
"command.model.choose.description": "Вибрати іншу модель",
"command.mcp.toggle": "Перемкнути MCP",
"command.mcp.toggle.description": "Перемкнути MCP",
"command.agent.cycle": "Перемкнути агента",
"command.agent.cycle.description": "Перемкнути на наступного агента",
"command.agent.cycle.reverse": "Перемкнути агента в зворотному напрямку",
"command.agent.cycle.reverse.description": "Перемкнути на попереднього агента",
"command.model.variant.cycle": "Перемкнути рівень мислення",
"command.model.variant.cycle.description": "Перемкнути на наступний рівень зусилля",
"command.prompt.mode.shell": "Команда",
"command.prompt.mode.normal": "Запит",
"command.permissions.autoaccept.enable": "Автоматично приймати дозволи",
"command.permissions.autoaccept.disable": "Зупинити автоматичне прийняття дозволів",
"command.workspace.toggle": "Перемкнути робочі області",
"command.workspace.toggle.description": "Увімкнути або вимкнути декілька робочих областей на бічній панелі",
"command.session.undo": "Скасувати",
"command.session.undo.description": "Скасувати останнє повідомлення",
"command.session.redo": "Повторити",
"command.session.redo.description": "Повторити останнє скасоване повідомлення",
"command.session.compact": "Стиснути сесію",
"command.session.compact.description": "Підсумувати сесію, щоб зменшити розмір контексту",
"command.session.fork": "Відгалузити від повідомлення",
"command.session.fork.description": "Створити нову сесію з попереднього повідомлення",
"command.session.share": "Поділитися сесією",
"command.session.share.description": "Поділитися цією сесією та скопіювати URL у буфер обміну",
"command.session.unshare": "Припинити поширення сесії",
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
"dialog.provider.search.placeholder": "Пошук провайдерів",
"dialog.provider.empty": "Провайдерів не знайдено",
"dialog.provider.group.popular": "Популярні",
"dialog.provider.group.other": "Інші",
"dialog.provider.tag.recommended": "Рекомендовані",
"dialog.provider.opencode.note": "Відібрані моделі, включаючи Claude, GPT, Gemini та інші",
"dialog.provider.opencode.tagline": "Надійні оптимізовані моделі",
"dialog.provider.opencodeGo.tagline": "Недорога підписка для всіх",
"dialog.provider.anthropic.note": "Прямий доступ до моделей Claude, включаючи Pro та Max",
"dialog.provider.copilot.note": "Моделі AI для допомоги в кодуванні через GitHub Copilot",
"dialog.provider.openai.note": "Моделі GPT для швидких і універсальних завдань AI",
"dialog.provider.google.note": "Моделі Gemini для швидких структурованих відповідей",
"dialog.provider.openrouter.note": "Доступ до всіх підтримуваних моделей від одного провайдера",
"dialog.provider.vercel.note": "Уніфікований доступ до моделей AI з інтелектуальною маршрутизацією",
"dialog.model.select.title": "Вибрати модель",
"dialog.model.search.placeholder": "Пошук моделей",
"dialog.model.empty": "Немає результатів моделей",
"dialog.model.manage": "Керувати моделями",
"dialog.model.manage.description": "Налаштуйте, які моделі відображатимуться у виборі моделей.",
"dialog.model.manage.provider.toggle": "Перемкнути всі моделі {{provider}}",
"dialog.model.unpaid.freeModels.title": "Безкоштовні моделі від OpenCode",
"dialog.model.unpaid.addMore.title": "Додати більше моделей від популярних провайдерів",
"dialog.provider.viewAll": "Показати більше провайдерів",
"provider.connect.title": "Підключити {{provider}}",
"provider.connect.title.anthropicProMax": "Увійти з Claude Pro/Max",
"provider.connect.selectMethod": "Виберіть спосіб входу для {{provider}}.",
"provider.connect.method.apiKey": "Ключ API",
"provider.connect.status.inProgress": "Авторизація виконується...",
"provider.connect.status.waiting": "Очікування авторизації...",
"provider.connect.status.failed": "Авторизація не вдалася: {{error}}",
"provider.connect.apiKey.description":
"Введіть ключ API {{provider}}, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.apiKey.label": "Ключ API {{provider}}",
"provider.connect.apiKey.placeholder": "Ключ API",
"provider.connect.apiKey.required": "Ключ API обов'язковий",
"provider.connect.opencodeZen.line1":
"OpenCode Zen надає доступ до відібраного набору надійних оптимізованих моделей для агентів кодування.",
"provider.connect.opencodeZen.line2":
"З одним ключем API ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.",
"provider.connect.opencodeZen.visit.prefix": "Відвідайте ",
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
"provider.connect.opencodeZen.visit.suffix": ", щоб отримати ключ API.",
"provider.connect.oauth.code.visit.prefix": "Відвідайте ",
"provider.connect.oauth.code.visit.link": "це посилання",
"provider.connect.oauth.code.visit.suffix":
", щоб отримати код авторизації, підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.code.label": "Код авторизації {{method}}",
"provider.connect.oauth.code.placeholder": "Код авторизації",
"provider.connect.oauth.code.required": "Код авторизації обов'язковий",
"provider.connect.oauth.code.invalid": "Недійсний код авторизації",
"provider.connect.oauth.auto.visit.prefix": "Відвідайте ",
"provider.connect.oauth.auto.visit.link": "це посилання",
"provider.connect.oauth.auto.visit.suffix":
" і введіть код нижче, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Код підтвердження",
"provider.connect.toast.connected.title": "{{provider}} підключено",
"provider.connect.toast.connected.description": "Моделі {{provider}} тепер доступні для використання.",
"provider.custom.title": "Користувацький провайдер",
"provider.custom.description.prefix": "Налаштуйте провайдера, сумісного з OpenAI. Перегляньте ",
"provider.custom.description.link": "документацію з налаштування провайдера",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID провайдера",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Малі літери, цифри, дефіси або підкреслення",
"provider.custom.field.name.label": "Відображувана назва",
"provider.custom.field.name.placeholder": "Мій AI Провайдер",
"provider.custom.field.baseURL.label": "Базовий URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "Ключ API",
"provider.custom.field.apiKey.placeholder": "Ключ API",
"provider.custom.field.apiKey.description":
"Необов'язково. Залиште порожнім, якщо ви керуєте авторизацією через заголовки.",
"provider.custom.models.label": "Моделі",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Назва",
"provider.custom.models.name.placeholder": "Відображувана назва",
"provider.custom.models.remove": "Видалити модель",
"provider.custom.models.add": "Додати модель",
"provider.custom.headers.label": "Заголовки (необов'язково)",
"provider.custom.headers.key.label": "Заголовок",
"provider.custom.headers.key.placeholder": "Назва-Заголовка",
"provider.custom.headers.value.label": "Значення",
"provider.custom.headers.value.placeholder": "значення",
"provider.custom.headers.remove": "Видалити заголовок",
"provider.custom.headers.add": "Додати заголовок",
"provider.custom.error.providerID.required": "ID провайдера обов'язкове",
"provider.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
"provider.custom.error.providerID.exists": "ID провайдера вже існує",
"provider.custom.error.name.required": "Відображувана назва обов'язкова",
"provider.custom.error.baseURL.required": "Базовий URL обов'язковий",
"provider.custom.error.baseURL.format": "Має починатися з http:// або https://",
"provider.custom.error.required": "Обов'язково",
"provider.custom.error.duplicate": "Дублікат",
"provider.disconnect.toast.disconnected.title": "{{provider}} відключено",
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
"model.provider.google": "Google",
"model.provider.xai": "xAI",
"model.provider.meta": "Meta",
"model.input.text": "текст",
"model.input.image": "зображення",
"model.input.audio": "аудіо",
"model.input.video": "відео",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Дозволяє: {{inputs}}",
"model.tooltip.reasoning.allowed": "Підтримує мислення",
"model.tooltip.reasoning.none": "Без мислення",
"model.tooltip.context": "Ліміт контексту {{limit}}",
"common.search.placeholder": "Пошук",
"common.goBack": "Назад",
"common.goForward": "Вперед",
"common.loading": "Завантаження",
"common.loading.ellipsis": "...",
"common.cancel": "Скасувати",
"common.open": "Відкрити",
"common.connect": "Підключити",
"common.disconnect": "Відключити",
"common.continue": "Продовжити",
"common.submit": "Надіслати",
"common.save": "Зберегти",
"common.saving": "Збереження...",
"common.default": "За замовчуванням",
"common.attachment": "вкладення",
"prompt.placeholder.shell": "Введіть команду термінала... {{example}}",
"prompt.placeholder.normal": 'Запитайте що завгодно... "{{example}}"',
"prompt.placeholder.simple": "Запитайте що завгодно...",
"prompt.placeholder.summarizeComments": "Підсумувати коментарі…",
"prompt.placeholder.summarizeComment": "Підсумувати коментар…",
"prompt.mode.shell": "Команда",
"prompt.mode.normal": "Запит",
"prompt.mode.shell.exit": "esc для виходу",
"session.child.promptDisabled": "Сесії підагентів не можна надсилати запити.",
"session.child.backToParent": "Назад до основної сесії.",
"prompt.example.1": "Виправити TODO у коді",
"prompt.example.2": "Який технологічний стек цього проєкту?",
"prompt.example.3": "Виправити зламані тести",
"prompt.example.4": "Пояснити, як працює автентифікація",
"prompt.example.5": "Знайти та виправити вразливості безпеки",
"prompt.example.6": "Додати модульні тести для сервісу користувача",
"prompt.example.7": "Рефакторити цю функцію, щоб зробити її більш читабельною",
"prompt.example.8": "Що означає ця помилка?",
"prompt.example.9": "Допоможіть мені налагодити цю проблему",
"prompt.example.10": "Згенерувати документацію API",
"prompt.example.11": "Оптимізувати запити до бази даних",
"prompt.example.12": "Додати валідацію введення",
"prompt.example.13": "Створити новий компонент для...",
"prompt.example.14": "Як розгорнути цей проєкт?",
"prompt.example.15": "Перевірити мій код на відповідність найкращим практикам",
"prompt.example.16": "Додати обробку помилок до цієї функції",
"prompt.example.17": "Пояснити цей регулярний вираз",
"prompt.example.18": "Конвертувати це в TypeScript",
"prompt.example.19": "Додати логування по всьому коду",
"prompt.example.20": "Які залежності застарілі?",
"prompt.example.21": "Допоможіть написати скрипт міграції",
"prompt.example.22": "Реалізувати кешування для цього ендпоінта",
"prompt.example.23": "Додати посторінкову навігацію до цього списку",
"prompt.example.24": "Створити команду CLI для...",
"prompt.example.25": "Як тут працюють змінні середовища?",
"prompt.popover.emptyResults": "Немає відповідних результатів",
"prompt.popover.emptyCommands": "Немає відповідних команд",
"prompt.dropzone.label": "Перетягніть сюди зображення, PDF або текстові файли",
"prompt.dropzone.file.label": "Перетягніть, щоб @згадати файл",
"prompt.slash.badge.custom": "користувацький",
"prompt.slash.badge.skill": "навичка",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "активний",
"prompt.context.includeActiveFile": "Включити активний файл",
"prompt.context.removeActiveFile": "Видалити активний файл з контексту",
"prompt.context.removeFile": "Видалити файл з контексту",
"prompt.action.attachFile": "Додати файли",
"prompt.attachment.remove": "Видалити вкладення",
"prompt.action.send": "Надіслати",
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
"prompt.toast.worktreeCreateFailed.title": "Не вдалося створити робоче дерево",
"prompt.toast.sessionCreateFailed.title": "Не вдалося створити сесію",
"prompt.toast.shellSendFailed.title": "Не вдалося надіслати команду термінала",
"prompt.toast.commandSendFailed.title": "Не вдалося надіслати команду",
"prompt.toast.promptSendFailed.title": "Не вдалося надіслати запит",
"prompt.toast.promptSendFailed.description": "Не вдалося отримати сесію",
"dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{enabled}} з {{total}} увімкнено",
"dialog.mcp.empty": "MCP не налаштовано",
"dialog.lsp.empty": "LSP автоматично виявлені за типами файлів",
"dialog.plugins.empty": "Плагіни налаштовані в opencode.json",
"mcp.status.connected": "підключено",
"mcp.status.failed": "помилка",
"mcp.status.needs_auth": "потрібна авторизація",
"mcp.status.disabled": "вимкнено",
"mcp.auth.clickToAuthenticate": "Натисніть для автентифікації",
"dialog.fork.empty": "Немає повідомлень для відгалуження",
"dialog.directory.search.placeholder": "Пошук папок",
"dialog.directory.empty": "Папок не знайдено",
"app.server.unreachable": "Не вдалося досягти {{server}}",
"app.server.retrying": "Автоматичне повторення...",
"app.server.otherServers": "Інші сервери",
"dialog.server.title": "Сервери",
"dialog.server.description": "Перемкніть сервер OpenCode, до якого підключається ця програма.",
"dialog.server.search.placeholder": "Пошук серверів",
"dialog.server.empty": "Ще немає серверів",
"dialog.server.add.title": "Додати сервер",
"dialog.server.add.url": "Адреса сервера",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Не вдалося підключитися до сервера",
"dialog.server.add.checking": "Перевірка...",
"dialog.server.add.button": "Додати сервер",
"dialog.server.add.name": "Назва сервера (необов'язково)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Ім'я користувача (необов'язково)",
"dialog.server.add.usernamePlaceholder": "ім'я користувача",
"dialog.server.add.password": "Пароль (необов'язково)",
"dialog.server.add.passwordPlaceholder": "пароль",
"dialog.server.edit.title": "Редагувати сервер",
"dialog.server.default.title": "Сервер за замовчуванням",
"dialog.server.default.description":
"Підключатися до цього сервера під час запуску програми замість запуску локального сервера. Потребує перезапуску.",
"dialog.server.default.none": "Сервер не вибрано",
"dialog.server.default.set": "Встановити поточний сервер як сервер за замовчуванням",
"dialog.server.default.clear": "Очистити",
"dialog.server.action.remove": "Видалити сервер",
"dialog.server.menu.edit": "Редагувати",
"dialog.server.menu.default": "Встановити за замовчуванням",
"dialog.server.menu.defaultRemove": "Видалити за замовчуванням",
"dialog.server.menu.delete": "Видалити",
"dialog.server.current": "Поточний сервер",
"dialog.server.status.default": "За замовчуванням",
"server.row.noUsername": "без імені користувача",
"dialog.project.edit.title": "Редагувати проєкт",
"dialog.project.edit.name": "Назва",
"dialog.project.edit.icon": "Іконка",
"dialog.project.edit.icon.alt": "Іконка проєкту",
"dialog.project.edit.icon.hint": "Натисніть або перетягніть зображення",
"dialog.project.edit.icon.recommended": "Рекомендовано: 128x128px",
"dialog.project.edit.color": "Колір",
"dialog.project.edit.color.select": "Вибрати колір {{color}}",
"dialog.project.edit.worktree.startup": "Скрипт запуску робочої області",
"dialog.project.edit.worktree.startup.description": "Виконується після створення нової робочої області (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "напр. bun install",
"dialog.releaseNotes.action.getStarted": "Розпочати",
"dialog.releaseNotes.action.next": "Далі",
"dialog.releaseNotes.action.hideFuture": "Не показувати це в майбутньому",
"dialog.releaseNotes.media.alt": "Попередній перегляд релізу",
"context.breakdown.title": "Розподіл контексту",
"context.breakdown.note":
'Приблизний розподіл вхідних токенів. "Інше" включає визначення інструментів і накладні витрати.',
"context.breakdown.system": "Система",
"context.breakdown.user": "Користувач",
"context.breakdown.assistant": "Асистент",
"context.breakdown.tool": "Виклики інструментів",
"context.breakdown.other": "Інше",
"context.systemPrompt.title": "Системний запит",
"context.rawMessages.title": "Сировинні повідомлення",
"context.stats.session": "Сесія",
"context.stats.messages": "Повідомлення",
"context.stats.provider": "Провайдер",
"context.stats.model": "Модель",
"context.stats.limit": "Ліміт контексту",
"context.stats.totalTokens": "Всього токенів",
"context.stats.usage": "Використання",
"context.stats.inputTokens": "Вхідні токени",
"context.stats.outputTokens": "Вихідні токени",
"context.stats.reasoningTokens": "Токени мислення",
"context.stats.cacheTokens": "Токени кешу (читання/запис)",
"context.stats.userMessages": "Повідомлення користувача",
"context.stats.assistantMessages": "Повідомлення асистента",
"context.stats.totalCost": "Загальна вартість",
"context.stats.sessionCreated": "Сесію створено",
"context.stats.lastActivity": "Остання активність",
"context.usage.tokens": "Токени",
"context.usage.usage": "Використання",
"context.usage.cost": "Вартість",
"context.usage.clickToView": "Натисніть, щоб переглянути контекст",
"context.usage.view": "Переглянути використання контексту",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Мова",
"toast.language.description": "Перемкнено на {{language}}",
"toast.theme.title": "Тему змінено",
"toast.scheme.title": "Кольорова схема",
"toast.workspace.enabled.title": "Робочі області увімкнено",
"toast.workspace.enabled.description": "Кілька робочих дерев тепер відображаються на бічній панелі",
"toast.workspace.disabled.title": "Робочі області вимкнено",
"toast.workspace.disabled.description": "Тільки головне робоче дерево відображається на бічній панелі",
"toast.permissions.autoaccept.on.title": "Автоматичне прийняття дозволів",
"toast.permissions.autoaccept.on.description": "Запити дозволів будуть автоматично схвалюватися",
"toast.permissions.autoaccept.off.title": "Автоматичне прийняття дозволів зупинено",
"toast.permissions.autoaccept.off.description": "Запити дозволів вимагатимуть схвалення",
"toast.model.none.title": "Модель не вибрано",
"toast.model.none.description": "Підключіть провайдера, щоб підсумувати цю сесію",
"toast.file.loadFailed.title": "Не вдалося завантажити файл",
"toast.file.listFailed.title": "Не вдалося отримати список файлів",
"toast.context.noLineSelection.title": "Не вибрано рядків",
"toast.context.noLineSelection.description": "Спочатку виберіть діапазон рядків у вкладці файлу.",
"toast.session.share.copyFailed.title": "Не вдалося скопіювати URL у буфер обміну",
"toast.session.share.success.title": "Сесію опубліковано",
"toast.session.share.success.description": "Посилання скопійовано в буфер обміну!",
"toast.session.share.failed.title": "Не вдалося опублікувати сесію",
"toast.session.share.failed.description": "Під час публікації сесії сталася помилка",
"toast.session.unshare.success.title": "Поширення сесії припинено",
"toast.session.unshare.success.description": "Поширення сесії успішно припинено!",
"toast.session.unshare.failed.title": "Не вдалося припинити поширення сесії",
"toast.session.unshare.failed.description": "Під час припинення поширення сесії сталася помилка",
"toast.session.listFailed.title": "Не вдалося завантажити сесії для {{project}}",
"toast.project.reloadFailed.title": "Не вдалося перезавантажити {{project}}",
"toast.update.title": "Доступне оновлення",
"toast.update.description": "Нова версія OpenCode ({{version}}) тепер доступна для встановлення.",
"toast.update.action.installRestart": "Встановити та перезапустити",
"toast.update.action.notYet": "Не зараз",
"error.page.title": "Щось пішло не так",
"error.page.description": "Під час завантаження програми сталася помилка.",
"error.page.details.label": "Деталі помилки",
"error.page.action.restart": "Перезапустити",
"error.page.action.report": "Повідомити про помилку",
"error.page.action.reported": "Помилку повідомлено",
"error.page.action.checking": "Перевірка...",
"error.page.action.checkUpdates": "Перевірити оновлення",
"error.page.action.updateTo": "Оновити до {{version}}",
"error.page.circular": "[Циклічне]",
"error.page.report.prefix": "Будь ласка, повідомте про цю помилку команді OpenCode",
"error.page.report.discord": "на Discord",
"error.page.version": "Версія: {{version}}",
"error.dev.rootNotFound":
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.globalSDK.noServerAvailable": "Сервер недоступний",
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
"error.childStore.storeCreateFailed": "Не вдалося створити сховище",
"directory.error.invalidUrl": "Недійсний каталог у URL.",
"error.chain.unknown": "Невідома помилка",
"error.server.invalidConfiguration": "Недійсна конфігурація",
"error.chain.causedBy": "Причина:",
"error.chain.apiError": "Помилка API",
"error.chain.status": "Статус: {{status}}",
"error.chain.retryable": "Повторювано: {{retryable}}",
"error.chain.responseBody": "Тіло відповіді:\n{{body}}",
"error.chain.didYouMean": "Можливо, ви мали на увазі: {{suggestions}}",
"error.chain.modelNotFound": "Модель не знайдено: {{provider}}/{{model}}",
"error.chain.checkConfig": "Перевірте назви провайдерів/моделей у конфігурації (opencode.json)",
"error.chain.mcpFailed":
'Сервер MCP "{{name}}" не працює. Зверніть увагу, OpenCode ще не підтримує автентифікацію MCP.',
"error.chain.providerAuthFailed": "Автентифікація провайдера не вдалася ({{provider}}): {{message}}",
"error.chain.providerInitFailed":
'Не вдалося ініціалізувати провайдера "{{provider}}". Перевірте облікові дані та конфігурацію.',
"error.chain.configJsonInvalid": "Файл конфігурації {{path}} не є дійсним JSON(C)",
"error.chain.configJsonInvalidWithMessage": "Файл конфігурації {{path}} не є дійсним JSON(C): {{message}}",
"error.chain.configDirectoryTypo":
'Каталог "{{dir}}" у {{path}} недійсний. Перейменуйте каталог на "{{suggestion}}" або видаліть його. Це поширена помилка.',
"error.chain.configFrontmatterError": "Не вдалося розібрати frontmatter у {{path}}:\n{{message}}",
"error.chain.configInvalid": "Файл конфігурації {{path}} недійсний",
"error.chain.configInvalidWithMessage": "Файл конфігурації {{path}} недійсний: {{message}}",
"notification.permission.title": "Потрібен дозвіл",
"notification.permission.description": "{{sessionTitle}} у {{projectName}} потребує дозволу",
"notification.question.title": "Запитання",
"notification.question.description": "{{sessionTitle}} у {{projectName}} має запитання",
"notification.action.goToSession": "Перейти до сесії",
"notification.session.responseReady.title": "Відповідь готова",
"notification.session.error.title": "Помилка сесії",
"notification.session.error.fallbackDescription": "Сталася помилка",
"home.recentProjects": "Нещодавні проєкти",
"home.empty.title": "Немає нещодавніх проєктів",
"home.empty.description": "Почніть, відкривши локальний проєкт",
"session.tab.session": "Сесія",
"session.tab.review": "Огляд",
"session.tab.context": "Контекст",
"session.panel.reviewAndFiles": "Огляд і файли",
"session.review.filesChanged": "Змінено файлів: {{count}}",
"session.review.change.one": "Зміна",
"session.review.change.other": "Зміни",
"session.review.loadingChanges": "Завантаження змін...",
"session.review.empty": "У цій сесії ще немає змін",
"session.review.noVcs": "Систему контролю версій Git не виявлено, зміни не відображаються",
"session.review.noVcs.createGit.title": "Створити Git-репозиторій",
"session.review.noVcs.createGit.description": "Відстежуйте, переглядайте та скасовуйте зміни в цьому проєкті",
"session.review.noVcs.createGit.actionLoading": "Створення Git-репозиторію...",
"session.review.noVcs.createGit.action": "Створити Git-репозиторій",
"session.review.noSnapshot": "Відстеження знімків вимкнено в конфігурації, тому зміни сесії недоступні",
"session.review.noChanges": "Немає змін",
"session.review.noUncommittedChanges": "Ще немає незафіксованих змін",
"session.review.noBranchChanges": "Ще немає змін у гілці",
"session.files.selectToOpen": "Виберіть файл для відкриття",
"session.files.all": "Усі файли",
"session.files.empty": "Немає файлів",
"session.files.binaryContent": "Бінарний файл (вміст не може бути відображено)",
"session.messages.renderEarlier": "Відобразити раніші повідомлення",
"session.messages.loadingEarlier": "Завантаження раніших повідомлень...",
"session.messages.loadEarlier": "Завантажити раніші повідомлення",
"session.messages.loading": "Завантаження повідомлень...",
"session.messages.jumpToLatest": "Перейти до останніх",
"session.context.addToContext": "Додати {{selection}} до контексту",
"session.todo.title": "Завдання",
"session.todo.collapse": "Згорнути",
"session.todo.expand": "Розгорнути",
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
"session.question.progress": "{{current}} з {{total}} запитань",
"session.followupDock.summary.one": "{{count}} повідомлення в черзі",
"session.followupDock.summary.other": "{{count}} повідомлень у черзі",
"session.followupDock.sendNow": "Надіслати зараз",
"session.followupDock.edit": "Редагувати",
"session.followupDock.collapse": "Згорнути повідомлення в черзі",
"session.followupDock.expand": "Розгорнути повідомлення в черзі",
"session.revertDock.summary.one": "{{count}} скасоване повідомлення",
"session.revertDock.summary.other": "{{count}} скасованих повідомлень",
"session.revertDock.collapse": "Згорнути скасовані повідомлення",
"session.revertDock.expand": "Розгорнути скасовані повідомлення",
"session.revertDock.restore": "Відновити повідомлення",
"session.new.title": "Створити що завгодно",
"session.new.worktree.main": "Основна гілка",
"session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})",
"session.new.worktree.create": "Створити нове робоче дерево",
"session.new.lastModified": "Востаннє змінено",
"session.header.search.placeholder": "Пошук {{project}}",
"session.header.searchFiles": "Пошук файлів",
"session.header.openIn": "Відкрити в",
"session.header.open.action": "Відкрити {{app}}",
"session.header.open.ariaLabel": "Відкрити в {{app}}",
"session.header.open.menu": "Параметри відкриття",
"session.header.open.copyPath": "Копіювати шлях",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Провідник файлів",
"session.header.open.fileManager": "Файловий менеджер",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Термінал",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Статус",
"status.popover.ariaLabel": "Конфігурації серверів",
"status.popover.tab.servers": "Сервери",
"status.popover.tab.mcp": "MCP",
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Плагіни",
"status.popover.action.manageServers": "Керувати серверами",
"session.share.popover.title": "Опублікувати в інтернеті",
"session.share.popover.description.shared":
"Ця сесія є публічною в інтернеті. Вона доступна будь-кому за посиланням.",
"session.share.popover.description.unshared":
"Опублікуйте сесію публічно в інтернеті. Вона буде доступна будь-кому за посиланням.",
"session.share.action.share": "Поділитися",
"session.share.action.publish": "Опублікувати",
"session.share.action.publishing": "Публікація...",
"session.share.action.unpublish": "Скасувати публікацію",
"session.share.action.unpublishing": "Скасування публікації...",
"session.share.action.view": "Переглянути",
"session.share.copy.copied": "Скопійовано",
"session.share.copy.copyLink": "Копіювати посилання",
"lsp.tooltip.none": "Немає серверів LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Завантаження запиту...",
"terminal.loading": "Завантаження термінала...",
"terminal.title": "Термінал",
"terminal.title.numbered": "Термінал {{number}}",
"terminal.close": "Закрити термінал",
"terminal.connectionLost.title": "З'єднання втрачено",
"terminal.connectionLost.abnormalClose": "WebSocket закрито аномально: {{code}}",
"terminal.connectionLost.description":
"З'єднання з терміналом було перервано. Це може статися під час перезапуску сервера.",
"common.closeTab": "Закрити вкладку",
"common.dismiss": "Відхилити",
"common.moreCountSuffix": " (ще {{count}})",
"common.requestFailed": "Запит не виконано",
"common.moreOptions": "Більше опцій",
"common.learnMore": "Дізнатися більше",
"common.rename": "Перейменувати",
"common.reset": "Скинути",
"common.archive": "Архівувати",
"common.delete": "Видалити",
"common.close": "Закрити",
"common.edit": "Редагувати",
"common.loadMore": "Завантажити більше",
"common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Пробіл",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "невідомо",
"common.time.justNow": "Щойно",
"common.time.minutesAgo.short": "{{count}} хв тому",
"common.time.hoursAgo.short": "{{count}} год тому",
"common.time.daysAgo.short": "{{count}} дн тому",
"sidebar.menu.toggle": "Перемкнути меню",
"sidebar.nav.projectsAndSessions": "Проєкти та сесії",
"sidebar.settings": "Налаштування",
"sidebar.help": "Довідка",
"sidebar.workspaces.enable": "Увімкнути робочі області",
"sidebar.workspaces.disable": "Вимкнути робочі області",
"sidebar.gettingStarted.title": "Початок роботи",
"sidebar.gettingStarted.line1": "OpenCode містить безкоштовні моделі, тому ви можете почати негайно.",
"sidebar.gettingStarted.line2":
"Підключіть будь-якого провайдера, щоб використовувати моделі, включаючи Claude, GPT, Gemini тощо.",
"sidebar.project.recentSessions": "Нещодавні сесії",
"sidebar.project.viewAllSessions": "Переглянути всі сесії",
"sidebar.project.clearNotifications": "Очистити сповіщення",
"sidebar.empty.title": "Немає відкритих проєктів",
"sidebar.empty.description": "Відкрийте проєкт, щоб почати",
"debugBar.ariaLabel": "Діагностика продуктивності розробки",
"debugBar.na": "н/д",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Останній завершений перехід маршруту, що торкається сторінки сесії, виміряний від запуску маршрутизатора до першого відображення після стабілізації.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Поточна кількість кадрів за секунду за останні 5 секунд.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Найгірший час кадру за останні 5 секунд.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Кадри понад 32 мс за останні 5 секунд.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Заблокований час і кількість довгих завдань за останні 5 секунд. Макс. завдання: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Найгірша спостережувана затримка введення за останні 5 секунд.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Приблизна тривалість взаємодії за останні 5 секунд. Це схоже на INP, а не на офіційний Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Сукупний зсув макета за весь час роботи програми.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Використана купа JS проти ліміту купи. Тільки Chromium.",
"debugBar.mem.tip": "Використана купа JS проти ліміту купи. {{used}} з {{limit}}.",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Робочий стіл",
"settings.section.server": "Сервер",
"settings.tab.general": "Загальні",
"settings.tab.shortcuts": "Скорочення",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "Інтеграція WSL",
"settings.desktop.wsl.description": "Запускати сервер OpenCode всередині WSL на Windows.",
"settings.general.section.appearance": "Зовнішній вигляд",
"settings.general.section.advanced": "Додатково",
"settings.general.section.notifications": "Системні сповіщення",
"settings.general.section.updates": "Оновлення",
"settings.general.section.sounds": "Звукові ефекти",
"settings.general.section.feed": "Стрічка",
"settings.general.section.display": "Дисплей",
"settings.general.row.language.title": "Мова",
"settings.general.row.language.description": "Змінити мову інтерфейсу OpenCode",
"settings.general.row.shell.title": "Командна оболонка термінала",
"settings.general.row.shell.description":
"Виберіть оболонку для термінала. Сумісні оболонки також використовуються для викликів інструментів агента.",
"settings.general.row.shell.autoDefault": "Авто (за замовчуванням)",
"settings.general.row.shell.terminalOnly": "тільки термінал",
"settings.general.row.appearance.title": "Зовнішній вигляд",
"settings.general.row.appearance.description": "Налаштуйте вигляд OpenCode на вашому пристрої",
"settings.general.row.colorScheme.title": "Кольорова схема",
"settings.general.row.colorScheme.description": "Виберіть, чи OpenCode використовує системну, світлу або темну тему",
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Налаштуйте тему OpenCode.",
"settings.general.row.font.title": "Шрифт коду",
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження",
"settings.general.row.followup.description": "Виберіть, чи продовження виконується негайно, чи чекає в черзі",
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description":
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
"settings.general.row.showSearch.description":
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
"settings.general.row.showTerminal.title": "Термінал",
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
"settings.general.row.shellToolPartsExpanded.description":
"Показувати частини інструменту оболонки розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
"settings.general.row.showSessionProgressBar.description":
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
"settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
"settings.general.row.wayland.tooltip":
"На Linux з моніторами з різною частотою оновлення нативний Wayland може бути більш стабільним.",
"settings.general.row.releaseNotes.title": "Нотатки до релізу",
"settings.general.row.releaseNotes.description": 'Показувати спливаючі вікна "Що нового" після оновлень',
"settings.updates.row.startup.title": "Перевіряти оновлення під час запуску",
"settings.updates.row.startup.description": "Автоматично перевіряти наявність оновлень під час запуску OpenCode",
"settings.updates.row.check.title": "Перевірити оновлення",
"settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні",
"settings.updates.action.checkNow": "Перевірити зараз",
"settings.updates.action.checking": "Перевірка...",
"settings.updates.toast.latest.title": "У вас актуальна версія",
"settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.",
"sound.option.none": "Немає",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
"sound.option.alert04": "Alert 04",
"sound.option.alert05": "Alert 05",
"sound.option.alert06": "Alert 06",
"sound.option.alert07": "Alert 07",
"sound.option.alert08": "Alert 08",
"sound.option.alert09": "Alert 09",
"sound.option.alert10": "Alert 10",
"sound.option.bipbop01": "Bip-bop 01",
"sound.option.bipbop02": "Bip-bop 02",
"sound.option.bipbop03": "Bip-bop 03",
"sound.option.bipbop04": "Bip-bop 04",
"sound.option.bipbop05": "Bip-bop 05",
"sound.option.bipbop06": "Bip-bop 06",
"sound.option.bipbop07": "Bip-bop 07",
"sound.option.bipbop08": "Bip-bop 08",
"sound.option.bipbop09": "Bip-bop 09",
"sound.option.bipbop10": "Bip-bop 10",
"sound.option.staplebops01": "Staplebops 01",
"sound.option.staplebops02": "Staplebops 02",
"sound.option.staplebops03": "Staplebops 03",
"sound.option.staplebops04": "Staplebops 04",
"sound.option.staplebops05": "Staplebops 05",
"sound.option.staplebops06": "Staplebops 06",
"sound.option.staplebops07": "Staplebops 07",
"sound.option.nope01": "Nope 01",
"sound.option.nope02": "Nope 02",
"sound.option.nope03": "Nope 03",
"sound.option.nope04": "Nope 04",
"sound.option.nope05": "Nope 05",
"sound.option.nope06": "Nope 06",
"sound.option.nope07": "Nope 07",
"sound.option.nope08": "Nope 08",
"sound.option.nope09": "Nope 09",
"sound.option.nope10": "Nope 10",
"sound.option.nope11": "Nope 11",
"sound.option.nope12": "Nope 12",
"sound.option.yup01": "Yup 01",
"sound.option.yup02": "Yup 02",
"sound.option.yup03": "Yup 03",
"sound.option.yup04": "Yup 04",
"sound.option.yup05": "Yup 05",
"sound.option.yup06": "Yup 06",
"settings.general.notifications.agent.title": "Агент",
"settings.general.notifications.agent.description":
"Показувати системне сповіщення, коли агент завершує роботу або потребує уваги",
"settings.general.notifications.permissions.title": "Дозволи",
"settings.general.notifications.permissions.description": "Показувати системне сповіщення, коли потрібен дозвіл",
"settings.general.notifications.errors.title": "Помилки",
"settings.general.notifications.errors.description": "Показувати системне сповіщення, коли виникає помилка",
"settings.general.sounds.agent.title": "Агент",
"settings.general.sounds.agent.description": "Відтворювати звук, коли агент завершує роботу або потребує уваги",
"settings.general.sounds.permissions.title": "Дозволи",
"settings.general.sounds.permissions.description": "Відтворювати звук, коли потрібен дозвіл",
"settings.general.sounds.errors.title": "Помилки",
"settings.general.sounds.errors.description": "Відтворювати звук, коли виникає помилка",
"settings.shortcuts.title": "Скорочення клавіш",
"settings.shortcuts.reset.button": "Скинути до стандартних",
"settings.shortcuts.reset.toast.title": "Скорочення скинуто",
"settings.shortcuts.reset.toast.description": "Скорочення клавіш були скинуті до стандартних.",
"settings.shortcuts.conflict.title": "Скорочення вже використовується",
"settings.shortcuts.conflict.description": "{{keybind}} вже призначено для {{titles}}.",
"settings.shortcuts.unassigned": "Не призначено",
"settings.shortcuts.pressKeys": "Натисніть клавіші",
"settings.shortcuts.search.placeholder": "Пошук скорочень",
"settings.shortcuts.search.empty": "Скорочень не знайдено",
"settings.shortcuts.group.general": "Загальні",
"settings.shortcuts.group.session": "Сесія",
"settings.shortcuts.group.navigation": "Навігація",
"settings.shortcuts.group.modelAndAgent": "Модель та агент",
"settings.shortcuts.group.terminal": "Термінал",
"settings.shortcuts.group.prompt": "Запит",
"settings.providers.title": "Провайдери",
"settings.providers.description": "Налаштування провайдерів будуть доступні тут.",
"settings.providers.section.connected": "Підключені провайдери",
"settings.providers.connected.empty": "Немає підключених провайдерів",
"settings.providers.connected.environmentDescription": "Підключено зі змінних середовища",
"settings.providers.section.popular": "Популярні провайдери",
"settings.providers.custom.description": "Додайте провайдера, сумісного з OpenAI, за базовим URL.",
"settings.providers.tag.environment": "Середовище",
"settings.providers.tag.config": "Конфігурація",
"settings.providers.tag.custom": "Користувацький",
"settings.providers.tag.other": "Інше",
"settings.models.title": "Моделі",
"settings.models.description": "Налаштування моделей будуть доступні тут.",
"settings.agents.title": "Агенти",
"settings.agents.description": "Налаштування агентів будуть доступні тут.",
"settings.commands.title": "Команди",
"settings.commands.description": "Налаштування команд будуть доступні тут.",
"settings.mcp.title": "MCP",
"settings.mcp.description": "Налаштування MCP будуть доступні тут.",
"settings.permissions.title": "Дозволи",
"settings.permissions.description": "Керуйте тим, які інструменти сервер може використовувати за замовчуванням.",
"settings.permissions.section.tools": "Інструменти",
"settings.permissions.toast.updateFailed.title": "Не вдалося оновити дозволи",
"settings.permissions.action.allow": "Дозволити",
"settings.permissions.action.ask": "Запитувати",
"settings.permissions.action.deny": "Заборонити",
"settings.permissions.tool.read.title": "Читання",
"settings.permissions.tool.read.description": "Читання файлу (відповідає шляху файлу)",
"settings.permissions.tool.edit.title": "Редагування",
"settings.permissions.tool.edit.description": "Зміна файлів, включаючи редагування, запис і патчі",
"settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Зіставлення файлів за допомогою glob-шаблонів",
"settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "Пошук вмісту файлів за допомогою регулярних виразів",
"settings.permissions.tool.list.title": "Список",
"settings.permissions.tool.list.description": "Список файлів у каталозі",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Запуск команд оболонки",
"settings.permissions.tool.task.title": "Завдання",
"settings.permissions.tool.task.description": "Запуск підагентів",
"settings.permissions.tool.skill.title": "Навичка",
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Пошук в інтернеті",
"settings.permissions.tool.external_directory.title": "Зовнішній каталог",
"settings.permissions.tool.external_directory.description": "Доступ до файлів за межами каталогу проєкту",
"settings.permissions.tool.doom_loop.title": "Цикл приреченості",
"settings.permissions.tool.doom_loop.description":
"Виявлення повторюваних викликів інструментів з однаковими вхідними даними",
"session.delete.failed.title": "Не вдалося видалити сесію",
"session.delete.title": "Видалити сесію",
"session.delete.confirm": 'Видалити сесію "{{name}}"?',
"session.delete.button": "Видалити сесію",
"workspace.new": "Нова робоча область",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",
"workspace.delete.failed.title": "Не вдалося видалити робочу область",
"workspace.resetting.title": "Скидання робочої області",
"workspace.resetting.description": "Це може зайняти хвилину.",
"workspace.reset.failed.title": "Не вдалося скинути робочу область",
"workspace.reset.success.title": "Робочу область скинуто",
"workspace.reset.success.description": "Робоча область тепер відповідає гілці за замовчуванням.",
"workspace.error.stillPreparing": "Робоча область все ще готується",
"workspace.status.checking": "Перевірка незлитих змін...",
"workspace.status.error": "Не вдалося перевірити статус git.",
"workspace.status.clean": "Незлитих змін не виявлено.",
"workspace.status.dirty": "Виявлено незлиті зміни в цій робочій області.",
"workspace.delete.title": "Видалити робочу область",
"workspace.delete.confirm": 'Видалити робочу область "{{name}}"?',
"workspace.delete.button": "Видалити робочу область",
"workspace.reset.title": "Скинути робочу область",
"workspace.reset.confirm": 'Скинути робочу область "{{name}}"?',
"workspace.reset.button": "Скинути робочу область",
"workspace.reset.archived.none": "Жодна активна сесія не буде заархівована.",
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
}
@@ -1,19 +1,4 @@
import { import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
createEffect,
createMemo,
createSignal,
For,
Index,
Match,
Switch,
on,
onCleanup,
Show,
mapArray,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
@@ -995,26 +980,26 @@ export function MessageTimeline(props: {
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => { const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
if (untrack(row).group.type === "context") { if (row.group.type === "context") {
const parts = createMemo(() => { const parts = createMemo(() => {
const group = row().group const group = row.group
if (group.type !== "context") return emptyTools if (group.type !== "context") return emptyTools
return group.refs return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID)) .map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool") .filter((part): part is ToolPart => part?.type === "tool")
}) })
return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} /> return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
} }
const message = createMemo(() => { const message = createMemo(() => {
const group = row().group const group = row.group
if (group.type !== "part") return if (group.type !== "part") return
return messageByID().get(group.ref.messageID) return messageByID().get(group.ref.messageID)
}) })
const part = createMemo(() => { const part = createMemo(() => {
const group = row().group const group = row.group
if (group.type !== "part") return if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID) return getMsgPart(group.ref.messageID, group.ref.partID)
}) })
@@ -1027,8 +1012,8 @@ export function MessageTimeline(props: {
<MessagePart <MessagePart
part={part()} part={part()}
message={message()} message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)} turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen( defaultOpen={partDefaultOpen(
part(), part(),
settings.general.shellToolPartsExpanded(), settings.general.shellToolPartsExpanded(),
@@ -1043,25 +1028,25 @@ export function MessageTimeline(props: {
) )
} }
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) { function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
const anchor = () => { const anchor = () => {
const row = input.row() const row = input.row
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
} }
const previousUserMessage = () => { const previousUserMessage = () => {
const row = input.row() const row = input.row
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
} }
const previousAssistantPart = () => { const previousAssistantPart = () => {
const row = input.row() const row = input.row
return row._tag === "AssistantPart" && row.previousAssistantPart return row._tag === "AssistantPart" && row.previousAssistantPart
} }
return ( return (
<div <div
id={anchor() ? props.anchor(input.row().userMessageID) : undefined} id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row().userMessageID} data-message-id={input.row.userMessageID}
data-timeline-row={input.row()._tag} data-timeline-row={input.row._tag}
classList={{ classList={{
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -1077,15 +1062,14 @@ export function MessageTimeline(props: {
) )
} }
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => { const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row()._tag) { switch (row._tag) {
case "CommentStrip": { case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() => const comments = createMemo(() =>
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
) )
return ( return (
<TimelineRowFrame row={commentStripRow}> <TimelineRowFrame row={row}>
<div class="w-full px-4 md:px-5 pb-2"> <div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar"> <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2"> <div class="flex w-max min-w-full justify-end gap-2">
@@ -1118,22 +1102,17 @@ export function MessageTimeline(props: {
) )
} }
case "UserMessage": { case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => { const message = createMemo(() => {
const m = messageByID().get(userMessageRow().userMessageID) const m = messageByID().get(row.userMessageID)
if (m?.role === "user") return m if (m?.role === "user") return m
}) })
return ( return (
<TimelineRowFrame row={userMessageRow}> <TimelineRowFrame row={row}>
<Show when={message()}> <Show when={message()}>
{(message) => ( {(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off"> <div data-slot="session-turn-message-content" aria-live="off">
<Message <Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
/>
</div> </div>
</div> </div>
)} )}
@@ -1142,14 +1121,13 @@ export function MessageTimeline(props: {
) )
} }
case "TurnDivider": { case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return ( return (
<TimelineRowFrame row={turnDividerRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction"> <div data-slot="session-turn-compaction">
<MessageDivider <MessageDivider
label={language.t( label={language.t(
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted", row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)} )}
/> />
</div> </div>
@@ -1158,27 +1136,22 @@ export function MessageTimeline(props: {
) )
} }
case "AssistantPart": { case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return ( return (
<TimelineRowFrame row={assistantPartRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div <div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
data-slot="session-turn-assistant-content" {renderAssistantPartGroup(row)}
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
>
{renderAssistantPartGroup(assistantPartRow)}
</div> </div>
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
) )
} }
case "Thinking": { case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return ( return (
<TimelineRowFrame row={thinkingRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow <TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading} reasoningHeading={row.reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()} showReasoningSummaries={settings.general.showReasoningSummaries()}
/> />
</div> </div>
@@ -1186,32 +1159,29 @@ export function MessageTimeline(props: {
) )
} }
case "Retry": { case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return ( return (
<TimelineRowFrame row={retryRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} /> <SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
) )
} }
case "DiffSummary": { case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return ( return (
<TimelineRowFrame row={diffSummaryRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} /> <TimelineDiffSummaryRow diffs={row.diffs} />
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
) )
} }
case "Error": { case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return ( return (
<TimelineRowFrame row={errorRow}> <TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card"> <Card variant="error" class="error-card">
{errorRow().text} {row.text}
</Card> </Card>
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
@@ -1223,7 +1193,11 @@ export function MessageTimeline(props: {
} }
function TimelineRowView(props: { rowKey: string }) { function TimelineRowView(props: { rowKey: string }) {
return <Show when={timelineRowByKey().get(props.rowKey)}>{(item) => renderTimelineRow(item)}</Show> return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
} }
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+2
View File
@@ -11,6 +11,7 @@ import { dict as da } from "~/i18n/da"
import { dict as ja } from "~/i18n/ja" import { dict as ja } from "~/i18n/ja"
import { dict as pl } from "~/i18n/pl" import { dict as pl } from "~/i18n/pl"
import { dict as ru } from "~/i18n/ru" import { dict as ru } from "~/i18n/ru"
import { dict as uk } from "~/i18n/uk"
import { dict as ar } from "~/i18n/ar" import { dict as ar } from "~/i18n/ar"
import { dict as no } from "~/i18n/no" import { dict as no } from "~/i18n/no"
import { dict as br } from "~/i18n/br" import { dict as br } from "~/i18n/br"
@@ -35,6 +36,7 @@ export function i18n(locale: Locale): Dict {
if (locale === "ja") return { ...base, ...ja } if (locale === "ja") return { ...base, ...ja }
if (locale === "pl") return { ...base, ...pl } if (locale === "pl") return { ...base, ...pl }
if (locale === "ru") return { ...base, ...ru } if (locale === "ru") return { ...base, ...ru }
if (locale === "uk") return { ...base, ...uk }
if (locale === "ar") return { ...base, ...ar } if (locale === "ar") return { ...base, ...ar }
if (locale === "no") return { ...base, ...no } if (locale === "no") return { ...base, ...no }
if (locale === "br") return { ...base, ...br } if (locale === "br") return { ...base, ...br }
+785
View File
@@ -0,0 +1,785 @@
import { dict as en } from "./en"
export const dict = {
...en,
"nav.github": "GitHub",
"nav.docs": "Документація",
"nav.changelog": "Журнал змін",
"nav.discord": "Discord",
"nav.x": "X",
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Увійти",
"nav.free": "Завантажити",
"nav.home": "Головна",
"nav.openMenu": "Відкрити меню",
"nav.getStartedFree": "Почати безкоштовно",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Копіювати логотип як SVG",
"nav.context.copyWordmark": "Копіювати знак як SVG",
"nav.context.brandAssets": "Бренд-матеріали",
"footer.github": "GitHub",
"footer.docs": "Документація",
"footer.changelog": "Журнал змін",
"footer.discord": "Discord",
"footer.x": "X",
"legal.brand": "Бренд",
"legal.privacy": "Конфіденційність",
"legal.terms": "Умови",
"email.title": "Дізнайтеся першими про нові продукти",
"email.subtitle": "Приєднуйтесь до списку очікування для раннього доступу.",
"email.placeholder": "Електронна адреса",
"email.subscribe": "Підписатися",
"email.success": "Майже готово! Перевірте пошту та підтвердьте адресу",
"notFound.title": "Не знайдено | opencode",
"notFound.heading": "404 — Сторінку не знайдено",
"notFound.home": "Головна",
"notFound.docs": "Документація",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "світлий логотип opencode",
"notFound.logoDarkAlt": "темний логотип opencode",
"user.logout": "Вийти",
"auth.callback.error.codeMissing": "Не знайдено код авторизації.",
"workspace.select": "Виберіть робочий простір",
"workspace.createNew": "+ Створити новий робочий простір",
"workspace.modal.title": "Створити новий робочий простір",
"workspace.modal.placeholder": "Введіть назву робочого простору",
"common.cancel": "Скасувати",
"common.creating": "Створення...",
"common.create": "Створити",
"common.contactUs": "Зв'яжіться з нами",
"common.videoUnsupported": "Ваш браузер не підтримує відео.",
"common.figure": "Рис. {{n}}.",
"common.faq": "FAQ",
"common.learnMore": "Дізнатися більше",
"error.invalidPlan": "Недійсний план",
"error.workspaceRequired": "ID робочого простору обов'язкове",
"error.alreadySubscribed": "Цей робочий простір уже має підписку",
"error.limitRequired": "Ліміт обов'язковий.",
"error.monthlyLimitInvalid": "Встановіть дійсний місячний ліміт.",
"error.workspaceNameRequired": "Назва робочого простору обов'язкова.",
"error.nameTooLong": "Назва має містити не більше 255 символів.",
"error.emailRequired": "Електронна адреса обов'язкова",
"error.roleRequired": "Роль обов'язкова",
"error.idRequired": "ID обов'язкове",
"error.nameRequired": "Назва обов'язкова",
"error.providerRequired": "Провайдер обов'язковий",
"error.apiKeyRequired": "Ключ API обов'язковий",
"error.modelRequired": "Модель обов'язкова",
"error.reloadAmountMin": "Сума поповнення має бути щонайменше ${{amount}}",
"error.reloadTriggerMin": "Поріг балансу має бути щонайменше ${{amount}}",
"app.meta.description": "OpenCode — відкритий агент для програмування.",
"home.title": "OpenCode | Відкритий AI-агент для кодування",
"temp.title": "opencode | AI-агент для кодування, створений для термінала",
"temp.hero.title": "AI-агент для кодування, створений для термінала",
"temp.zen": "opencode zen",
"temp.getStarted": "Почати",
"temp.feature.native.title": "Рідний TUI",
"temp.feature.native.body": "Чуйний, рідний інтерфейс термінала з темами",
"temp.feature.zen.beforeLink": "A",
"temp.feature.zen.link": "добірка моделей",
"temp.feature.zen.afterLink": "від opencode",
"temp.feature.models.beforeLink": "Підтримує 75+ LLM-провайдерів через",
"temp.feature.models.afterLink": ", включаючи локальні моделі",
"temp.screenshot.caption": "OpenCode TUI з темою tokyonight",
"temp.screenshot.alt": "OpenCode TUI з темою tokyonight",
"temp.logoLightAlt": "світлий логотип opencode",
"temp.logoDarkAlt": "темний логотип opencode",
"home.banner.badge": "Нове",
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
"home.banner.platforms": "на macOS, Windows та Linux",
"home.banner.downloadNow": "Завантажити зараз",
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
"home.hero.title": "Відкритий AI-агент для кодування",
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
"home.hero.subtitle.b": "включно з Claude, GPT, Gemini та іншими.",
"home.install.ariaLabel": "Параметри встановлення",
"home.what.title": "Що таке OpenCode?",
"home.what.body": "OpenCode — це відкритий агент, який допомагає писати код у терміналі, IDE або на десктопі.",
"home.what.lsp.title": "LSP увімкнено",
"home.what.lsp.body": "Автоматично завантажує потрібні LSP для LLM",
"home.what.multiSession.title": "Багатосесійність",
"home.what.multiSession.body": "Запускайте кількох агентів паралельно в одному проекті",
"home.what.shareLinks.title": "Посилання для обміну",
"home.what.shareLinks.body": "Діліться посиланням на будь-яку сесію для обговорення або налагодження",
"home.what.copilot.title": "GitHub Copilot",
"home.what.copilot.body": "Увійдіть через GitHub, щоб використовувати свій обліковий запис Copilot",
"home.what.chatgptPlus.title": "ChatGPT Plus/Pro",
"home.what.chatgptPlus.body": "Увійдіть через OpenAI, щоб використовувати ChatGPT Plus або Pro",
"home.what.anyModel.title": "Будь-яка модель",
"home.what.anyModel.body": "75+ LLM-провайдерів через Models.dev, включаючи локальні моделі",
"home.what.anyEditor.title": "Будь-який редактор",
"home.what.anyEditor.body": "Доступний як термінальний інтерфейс, десктопний застосунок та розширення IDE",
"home.what.readDocs": "Читати документацію",
"home.growth.title": "Відкритий AI-агент для кодування",
"home.growth.body":
"З понад <strong>{{stars}}</strong> зірками на GitHub, <strong>{{contributors}}</strong> учасниками та понад <strong>{{commits}}</strong> комітами, OpenCode використовують понад <strong>{{monthlyUsers}}</strong> розробників щомісяця.",
"home.growth.githubStars": "Зірки GitHub",
"home.growth.contributors": "Учасники",
"home.growth.monthlyDevs": "Розробників на місяць",
"home.privacy.title": "Створено для конфіденційності",
"home.privacy.body":
"OpenCode не зберігає ваш код або контекстні дані, тому може працювати в середовищах з чутливими даними.",
"home.privacy.learnMore": "Дізнатися більше про",
"home.privacy.link": "конфіденційність",
"home.faq.q1": "Що таке OpenCode?",
"home.faq.a1":
"OpenCode — це відкритий агент, який допомагає писати та запускати код з будь-якою AI-моделлю. Доступний як термінальний інтерфейс, десктопний застосунок або розширення IDE.",
"home.faq.q2": "Як почати користуватися OpenCode?",
"home.faq.a2.before": "Найпростіший спосіб почати — прочитати",
"home.faq.a2.link": "вступ",
"home.faq.q3": "Чи потрібні додаткові AI-підписки для використання OpenCode?",
"home.faq.a3.p1":
"Не обов'язково, OpenCode має набір безкоштовних моделей, які можна використовувати без реєстрації.",
"home.faq.a3.p2.beforeZen":
"Крім цього, ви можете використовувати будь-які популярні моделі, створивши обліковий запис",
"home.faq.a3.p2.afterZen": ".",
"home.faq.a3.p3":
"Хоча ми рекомендуємо Zen, OpenCode також працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
"home.faq.a3.p4.beforeLocal": "Ви навіть можете підключити свої",
"home.faq.a3.p4.localLink": "локальні моделі",
"home.faq.q4": "Чи можу я використовувати свої наявні AI-підписки з OpenCode?",
"home.faq.a4.p1":
"Так, OpenCode підтримує підписки всіх основних провайдерів. Ви можете використовувати Claude Pro/Max, ChatGPT Plus/Pro або GitHub Copilot.",
"home.faq.q5": "Чи можна використовувати OpenCode лише в терміналі?",
"home.faq.a5.beforeDesktop": "Вже ні! OpenCode тепер доступний як застосунок для",
"home.faq.a5.desktop": "десктопа",
"home.faq.a5.and": "та",
"home.faq.a5.web": "вебу",
"home.faq.q6": "Скільки коштує OpenCode?",
"home.faq.a6":
"OpenCode є 100% безкоштовним. Він також має набір безкоштовних моделей. Додаткові витрати можливі, якщо ви підключите іншого провайдера.",
"home.faq.q7": "А як щодо даних та конфіденційності?",
"home.faq.a7.p1":
"Ваші дані зберігаються лише тоді, коли ви використовуєте безкоштовні моделі або створюєте посилання для обміну.",
"home.faq.a7.p2.beforeModels": "Дізнайтеся більше про",
"home.faq.a7.p2.modelsLink": "наші моделі",
"home.faq.a7.p2.and": "та",
"home.faq.a7.p2.shareLink": "сторінки обміну",
"home.faq.q8": "Чи є OpenCode відкритим?",
"home.faq.a8.p1": "Так, OpenCode повністю відкритий. Вихідний код доступний публічно на",
"home.faq.a8.p2": "під ліцензією",
"home.faq.a8.mitLicense": "MIT License",
"home.faq.a8.p3":
", тобто кожен може використовувати, змінювати або сприяти його розвитку. Будь-хто зі спільноти може створювати issues, надсилати pull request'и та розширювати функціональність.",
"home.zenCta.title": "Отримайте доступ до надійних оптимізованих моделей для агентів кодування",
"home.zenCta.body":
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
"home.zenCta.link": "Дізнатися про Zen",
"zen.title": "OpenCode Zen | Добірка надійних оптимізованих моделей для агентів кодування",
"zen.hero.title": "Надійні оптимізовані моделі для агентів кодування",
"zen.hero.body":
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
"zen.faq.q1": "Що таке OpenCode Zen?",
"zen.faq.a1": "Zen — це добірка AI-моделей, протестованих для агентів кодування, створена командою OpenCode.",
"zen.faq.q2": "Чому Zen точніший?",
"zen.faq.a2":
"Zen надає лише моделі, спеціально протестовані для агентів кодування. Ви ж не використовуєте масло ніж для стейка — не використовуйте погані моделі для кодування.",
"zen.faq.q3": "Чи Zen дешевший?",
"zen.faq.a3":
"Zen не є прибутковим. Zen передає вам вартість від провайдерів моделей. Чим вище використання Zen, тим кращі ціни OpenCode може узгодити та передати вам.",
"zen.faq.q4": "Скільки коштує Zen?",
"zen.faq.a4.p1.beforePricing": "Zen",
"zen.faq.a4.p1.pricingLink": "стягує плату за запит",
"zen.faq.a4.p1.afterPricing": "без націнок — ви платите рівно стільки, скільки стягує провайдер моделі.",
"zen.faq.a4.p2.beforeAccount": "Загальна вартість залежить від використання. Ви можете встановити місячні ліміти в",
"zen.faq.a4.p2.accountLink": "обліковому записі",
"zen.faq.a4.p3":
"Щоб покрити витрати, OpenCode додає лише невелику комісію за обробку платежу в розмірі $1.23 за кожне поповнення балансу $20.",
"zen.faq.q5": "А як щодо даних та конфіденційності?",
"zen.faq.a5.beforeExceptions":
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"zen.faq.a5.exceptionsLink": "такими винятками",
"zen.faq.q6": "Чи можна встановити ліміти витрат?",
"zen.faq.a6": "Так, ви можете встановити місячні ліміти витрат в обліковому записі.",
"zen.faq.q7": "Чи можна скасувати?",
"zen.faq.a7": "Так, ви можете вимкнути оплату в будь-який час і використовувати залишок.",
"zen.faq.q8": "Чи можна використовувати Zen з іншими агентами кодування?",
"zen.faq.a8":
"Хоча Zen чудово працює з OpenCode, ви можете використовувати Zen з будь-яким агентом. Дотримуйтесь інструкцій з налаштування у вашому агенті.",
"zen.cta.start": "Почати з Zen",
"zen.pricing.title": "Додати $20 балансу Pay as you go",
"zen.pricing.fee": "(+$1.23 комісія за обробку карти)",
"zen.pricing.body": "Використовуйте з будь-яким агентом. Встановлюйте місячні ліміти. Скасуйте в будь-який час.",
"zen.problem.title": "Яку проблему вирішує Zen?",
"zen.problem.body":
"Доступно багато моделей, але лише деякі добре працюють з агентами кодування. Більшість провайдерів налаштовують їх по-різному з різними результатами.",
"zen.problem.subtitle": "Ми вирішуємо це для всіх, а не лише для користувачів OpenCode.",
"zen.problem.item1": "Тестування вибраних моделей та консультації з їхніми командами",
"zen.problem.item2": "Співпраця з провайдерами для забезпечення правильної доставки",
"zen.problem.item3": "Бенчмаркінг усіх комбінацій моделей та провайдерів, які ми рекомендуємо",
"zen.how.title": "Як працює Zen",
"zen.how.body":
"Хоча ми пропонуємо використовувати Zen з OpenCode, ви можете використовувати Zen з будь-яким агентом.",
"zen.how.step1.title": "Зареєструйтеся та додайте $20 балансу",
"zen.how.step1.beforeLink": "дотримуйтесь",
"zen.how.step1.link": "інструкцій з налаштування",
"zen.how.step2.title": "Використовуйте Zen із прозорими цінами",
"zen.how.step2.link": "платіть за запит",
"zen.how.step2.afterLink": "без націнок",
"zen.how.step3.title": "Автоматичне поповнення",
"zen.how.step3.body": "коли баланс досягає $5, ми автоматично додаємо $20",
"zen.privacy.title": "Ваша конфіденційність важлива для нас",
"zen.privacy.beforeExceptions":
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.hero.title": "Недорогі моделі кодування для всіх",
"go.hero.body":
"Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
"go.cta.start": "Підписатися на Go",
"go.cta.template": "{{text}} {{price}}",
"go.cta.text": "Підписатися на Go",
"go.cta.price": "$10/місяць",
"go.cta.promo": "$5 перший місяць",
"go.pricing.body":
"Використовуйте з будь-яким агентом. $5 перший місяць, потім $10/місяць. Поповнюйте за потреби. Скасуйте в будь-який час.",
"go.graph.free": "Безкоштовно",
"go.graph.freePill": "Big Pickle та безкоштовні моделі",
"go.graph.go": "Go",
"go.graph.label": "Запитів за 5 годин",
"go.graph.usageLimits": "Ліміти використання",
"go.graph.tick": "{{n}}x",
"go.graph.aria": "Запитів за 5 год: {{free}} vs {{go}}",
"go.testimonials.brand.zen": "Zen",
"go.testimonials.brand.go": "Go",
"go.testimonials.handle": "@OpenCode",
"go.testimonials.dax.name": "Dax Raad",
"go.testimonials.dax.title": "ex-CEO, Terminal Products",
"go.testimonials.dax.quoteAfter": "змінило моє життя, це справді очевидний вибір.",
"go.testimonials.jay.name": "Jay V",
"go.testimonials.jay.title": "ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint",
"go.testimonials.jay.quoteBefore": "4 з 5 людей у нашій команді люблять використовувати",
"go.testimonials.jay.quoteAfter": ".",
"go.testimonials.adam.name": "Adam Elmore",
"go.testimonials.adam.title": "ex-Hero, AWS",
"go.testimonials.adam.quoteBefore": "Я не можу достатньо рекомендувати",
"go.testimonials.adam.quoteAfter": ". Серйозно, це дійсно добре.",
"go.testimonials.david.name": "David Hill",
"go.testimonials.david.title": "ex-Head of Design, Laravel",
"go.testimonials.david.quoteBefore": "Завдяки",
"go.testimonials.david.quoteAfter": "я знаю, що всі моделі протестовані та ідеальні для агентів кодування.",
"go.testimonials.frank.name": "Frank Wang",
"go.testimonials.frank.title": "ex-Intern, Nvidia (4 times)",
"go.testimonials.frank.quote": "Хотів би я досі бути в Nvidia.",
"go.problem.title": "Яку проблему вирішує Go?",
"go.problem.body":
"Ми зосереджені на тому, щоб зробити досвід OpenCode доступним для якомога більшої кількості людей. OpenCode Go — це недорога підписка: $5 за перший місяць, потім $10/місяць. Вона надає щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
"go.problem.subtitle": " ",
"go.problem.item1": "Недорога підписка",
"go.problem.item2": "Щедрі ліміти та надійний доступ",
"go.problem.item3": "Створено для якомога більшої кількості програмістів",
"go.problem.item4":
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash",
"go.how.title": "Як працює Go",
"go.how.body":
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
"go.how.step1.title": "Створіть обліковий запис",
"go.how.step1.beforeLink": "дотримуйтесь",
"go.how.step1.link": "інструкцій з налаштування",
"go.how.step2.title": "Підпишіться на Go",
"go.how.step2.link": "$5 перший місяць",
"go.how.step2.afterLink": "потім $10/місяць із щедрими лімітами",
"go.how.step3.title": "Почніть кодувати",
"go.how.step3.body": "з надійним доступом до моделей з відкритим кодом",
"go.privacy.title": "Ваша конфіденційність важлива для нас",
"go.privacy.body":
"План розроблений переважно для міжнародних користувачів, з моделями, розміщеними в США, ЄС та Сінгапурі для стабільного глобального доступу.",
"go.privacy.contactAfter": "якщо у вас є запитання.",
"go.privacy.beforeExceptions":
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"go.privacy.exceptionsLink": "такими винятками",
"go.faq.q1": "Що таке OpenCode Go?",
"go.faq.a1":
"Go — це недорога підписка, яка надає надійний доступ до найкращих моделей з відкритим кодом для агентного кодування.",
"go.faq.q2": "Які моделі включає Go?",
"go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.",
"go.faq.q3": "Чи Go те саме, що Zen?",
"go.faq.a3":
"Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом.",
"go.faq.q4": "Скільки коштує Go?",
"go.faq.a4.p1.beforePricing": "Go коштує",
"go.faq.a4.p1.pricingLink": "$5 за перший місяць",
"go.faq.a4.p1.afterPricing": "потім $10/місяць із щедрими лімітами.",
"go.faq.a4.p2.beforeAccount": "Ви можете керувати підпискою в",
"go.faq.a4.p2.accountLink": "обліковому записі",
"go.faq.a4.p3": "Скасуйте в будь-який час.",
"go.faq.q5": "А як щодо даних та конфіденційності?",
"go.faq.a5.body":
"План розроблений переважно для міжнародних користувачів, з моделями в США, ЄС та Сінгапурі. Провайдери дотримуються політики нульового зберігання.",
"go.faq.a5.beforeExceptions":
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"go.faq.a5.exceptionsLink": "такими винятками",
"go.faq.q6": "Чи можна поповнити баланс?",
"go.faq.a6": "Якщо вам потрібно більше використання, ви можете поповнити баланс в обліковому записі.",
"go.faq.q7": "Чи можна скасувати?",
"go.faq.a7": "Так, ви можете скасувати в будь-який час.",
"go.faq.q8": "Чи можна використовувати Go з іншими агентами кодування?",
"go.faq.a8": "Так, ви можете використовувати Go з будь-яким агентом.",
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
"go.faq.a9":
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
"zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.",
"zen.api.error.modelNotSupported": "Модель {{model}} не підтримується",
"zen.api.error.modelFormatNotSupported": "Модель {{model}} не підтримується для формату {{format}}",
"zen.api.error.noProviderAvailable": "Немає доступного провайдера",
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
"zen.api.error.missingApiKey": "Відсутній ключ API.",
"zen.api.error.invalidApiKey": "Недійсний ключ API.",
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
"Досягнуто тижневого ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
"Досягнуто місячного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.noPaymentMethod": "Немає способу оплати. Додайте метод оплати: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Недостатньо коштів. Керуйте оплатою: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Ваш робочий простір досяг місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель вимкнено",
"zen.api.error.trialEnded":
"Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}",
"black.meta.title": "OpenCode Black | Доступ до найкращих моделей кодування",
"black.meta.description": "Отримайте доступ до Claude, GPT, Gemini та інших із планами підписки OpenCode Black.",
"black.hero.title": "Доступ до найкращих моделей кодування",
"black.hero.subtitle": "Включаючи Claude, GPT, Gemini та інші",
"black.title": "OpenCode Black | Ціни",
"black.paused": "Реєстрація в план Black тимчасово призупинена.",
"black.plan.icon20": "План Black 20",
"black.plan.icon100": "План Black 100",
"black.plan.icon200": "План Black 200",
"black.plan.multiplier100": "5x більше використання ніж Black 20",
"black.plan.multiplier200": "20x більше використання ніж Black 20",
"black.price.perMonth": "на місяць",
"black.price.perPersonBilledMonthly": "за особу з щомісячною оплатою",
"black.terms.1": "Ваша підписка не розпочнеться негайно",
"black.terms.2": "Вас додадуть до списку очікування та активують незабаром",
"black.terms.3": "Картку буде списано лише після активації підписки",
"black.terms.4": "Діють ліміти використання, інтенсивне автоматичне використання може швидше вичерпати ліміти",
"black.terms.5": "Підписки призначені для фізичних осіб, зверніться в Enterprise для команд",
"black.terms.6": "Ліміти можуть бути змінені, а плани можуть бути припинені в майбутньому",
"black.terms.7": "Скасуйте підписку в будь-який час",
"black.action.continue": "Продовжити",
"black.finePrint.beforeTerms": "Зазначені ціни не включають податки",
"black.finePrint.terms": "Умови надання послуг",
"black.workspace.title": "OpenCode Black | Виберіть робочий простір",
"black.workspace.selectPlan": "Виберіть робочий простір для цього плану",
"black.workspace.name": "Робочий простір {{n}}",
"black.subscribe.title": "Підписатися на OpenCode Black",
"black.subscribe.paymentMethod": "Спосіб оплати",
"black.subscribe.loadingPaymentForm": "Завантаження форми оплати...",
"black.subscribe.selectWorkspaceToContinue": "Виберіть робочий простір для продовження",
"black.subscribe.failurePrefix": "Ой!",
"black.subscribe.error.generic": "Сталася помилка",
"black.subscribe.error.invalidPlan": "Недійсний план",
"black.subscribe.error.workspaceRequired": "ID робочого простору обов'язкове",
"black.subscribe.error.alreadySubscribed": "Цей робочий простір уже має підписку",
"black.subscribe.processing": "Обробка...",
"black.subscribe.submit": "Підписатися ${{plan}}",
"black.subscribe.form.chargeNotice": "Платіж буде списано лише після активації підписки",
"black.subscribe.success.title": "Ви в списку очікування OpenCode Black",
"black.subscribe.success.subscriptionPlan": "План підписки",
"black.subscribe.success.planName": "OpenCode Black {{plan}}",
"black.subscribe.success.amount": "Сума",
"black.subscribe.success.amountValue": "${{plan}} на місяць",
"black.subscribe.success.paymentMethod": "Спосіб оплати",
"black.subscribe.success.dateJoined": "Дата приєднання",
"black.subscribe.success.chargeNotice": "Вашу картку буде списано після активації підписки",
"workspace.nav.zen": "Zen",
"workspace.nav.go": "Go",
"workspace.nav.usage": "Використання",
"workspace.nav.apiKeys": "Ключі API",
"workspace.nav.members": "Учасники",
"workspace.nav.billing": "Оплата",
"workspace.nav.settings": "Налаштування",
"workspace.home.banner.beforeLink": "Надійні оптимізовані моделі для агентів кодування.",
"workspace.lite.banner.beforeLink": "Недорогі моделі кодування для всіх.",
"workspace.home.billing.loading": "Завантаження...",
"workspace.home.billing.enable": "Увімкнути оплату",
"workspace.home.billing.currentBalance": "Поточний баланс",
"workspace.newUser.feature.tested.title": "Протестовані та перевірені моделі",
"workspace.newUser.feature.tested.body":
"Ми протестували моделі спеціально для агентів кодування, щоб забезпечити найкращу продуктивність.",
"workspace.newUser.feature.quality.title": "Найвища якість",
"workspace.newUser.feature.quality.body":
"Доступ до моделей, налаштованих для оптимальної продуктивності — без зниження якості.",
"workspace.newUser.feature.lockin.title": "Без блокування (Lock-in)",
"workspace.newUser.feature.lockin.body":
"Використовуйте Zen з будь-яким агентом і продовжуйте користуватися іншими провайдерами.",
"workspace.newUser.copyApiKey": "Копіювати ключ API",
"workspace.newUser.copyKey": "Копіювати ключ",
"workspace.newUser.copied": "Скопійовано!",
"workspace.newUser.step.enableBilling": "Увімкнути оплату",
"workspace.newUser.step.login.before": "Запустіть",
"workspace.newUser.step.login.after": "і виберіть opencode",
"workspace.newUser.step.pasteKey": "Вставте ключ API",
"workspace.newUser.step.models.before": "Запустіть opencode і виконайте",
"workspace.newUser.step.models.after": "щоб вибрати модель",
"workspace.models.title": "Моделі",
"workspace.models.subtitle.beforeLink": "Керуйте доступом учасників до моделей.",
"workspace.models.table.model": "Модель",
"workspace.models.table.enabled": "Увімкнено",
"workspace.providers.title": "Принесіть власний ключ (BYOK)",
"workspace.providers.subtitle": "Налаштуйте власні ключі API від AI-провайдерів.",
"workspace.providers.placeholder": "Введіть ключ API {{provider}} ({{prefix}}...)",
"workspace.providers.configure": "Налаштувати",
"workspace.providers.edit": "Редагувати",
"workspace.providers.delete": "Видалити",
"workspace.providers.saving": "Збереження...",
"workspace.providers.save": "Зберегти",
"workspace.providers.table.provider": "Провайдер",
"workspace.providers.table.apiKey": "Ключ API",
"workspace.usage.title": "Історія використання",
"workspace.usage.subtitle": "Останнє використання API та витрати.",
"workspace.usage.empty": "Зробіть перший API-запит, щоб почати.",
"workspace.usage.table.date": "Дата",
"workspace.usage.table.model": "Модель",
"workspace.usage.table.input": "Вхід",
"workspace.usage.table.output": "Вихід",
"workspace.usage.table.cost": "Вартість",
"workspace.usage.table.session": "Сесія",
"workspace.usage.breakdown.input": "Вхід",
"workspace.usage.breakdown.cacheRead": "Читання кешу",
"workspace.usage.breakdown.cacheWrite": "Запис кешу",
"workspace.usage.breakdown.output": "Вихід",
"workspace.usage.breakdown.reasoning": "Міркування",
"workspace.usage.subscription": "Black (${{amount}})",
"workspace.usage.lite": "Go (${{amount}})",
"workspace.usage.byok": "BYOK (${{amount}})",
"workspace.cost.title": "Вартість",
"workspace.cost.subtitle": "Витрати в розрізі моделей.",
"workspace.cost.allModels": "Усі моделі",
"workspace.cost.allKeys": "Усі ключі",
"workspace.cost.deletedSuffix": "(видалено)",
"workspace.cost.empty": "Немає даних про використання за вибраний період.",
"workspace.cost.subscriptionShort": "підп",
"workspace.keys.title": "Ключі API",
"workspace.keys.subtitle": "Керуйте ключами API для доступу до сервісів opencode.",
"workspace.keys.create": "Створити ключ API",
"workspace.keys.placeholder": "Введіть назву ключа",
"workspace.keys.empty": "Створіть ключ API шлюзу opencode",
"workspace.keys.table.name": "Назва",
"workspace.keys.table.key": "Ключ",
"workspace.keys.table.createdBy": "Створено",
"workspace.keys.table.lastUsed": "Останнє використання",
"workspace.keys.copyApiKey": "Копіювати ключ API",
"workspace.keys.delete": "Видалити",
"workspace.members.title": "Учасники",
"workspace.members.subtitle": "Керуйте учасниками робочого простору та їхніми дозволами.",
"workspace.members.invite": "Запросити учасника",
"workspace.members.inviting": "Запрошення...",
"workspace.members.beta.beforeLink": "Робочі простори безкоштовні для команд під час бета-версії.",
"workspace.members.form.invitee": "Запрошений",
"workspace.members.form.emailPlaceholder": "Введіть email",
"workspace.members.form.role": "Роль",
"workspace.members.form.monthlyLimit": "Місячний ліміт витрат",
"workspace.members.noLimit": "Без ліміту",
"workspace.members.noLimitLowercase": "без ліміту",
"workspace.members.invited": "запрошено",
"workspace.members.edit": "Редагувати",
"workspace.members.delete": "Видалити",
"workspace.members.saving": "Збереження...",
"workspace.members.save": "Зберегти",
"workspace.members.table.email": "Email",
"workspace.members.table.role": "Роль",
"workspace.members.table.monthLimit": "Ліміт на місяць",
"workspace.members.role.admin": "Адміністратор",
"workspace.members.role.adminDescription": "Може керувати моделями, учасниками та оплатою",
"workspace.members.role.member": "Учасник",
"workspace.members.role.memberDescription": "Може створювати ключі API лише для себе",
"workspace.settings.title": "Налаштування",
"workspace.settings.subtitle": "Оновіть назву робочого простору та налаштування.",
"workspace.settings.workspaceName": "Назва робочого простору",
"workspace.settings.defaultName": "Стандартна",
"workspace.settings.updating": "Оновлення...",
"workspace.settings.save": "Зберегти",
"workspace.settings.edit": "Редагувати",
"workspace.billing.title": "Оплата",
"workspace.billing.subtitle.beforeLink": "Керуйте способами оплати.",
"workspace.billing.contactUs": "Зв'яжіться з нами",
"workspace.billing.subtitle.afterLink": "якщо у вас є запитання.",
"workspace.billing.currentBalance": "Поточний баланс",
"workspace.billing.add": "Додати $",
"workspace.billing.enterAmount": "Введіть суму",
"workspace.billing.loading": "Завантаження...",
"workspace.billing.addAction": "Додати",
"workspace.billing.addBalance": "Поповнити баланс",
"workspace.billing.alipay": "Alipay",
"workspace.billing.wechat": "WeChat Pay",
"workspace.billing.linkedToStripe": "Підключено до Stripe",
"workspace.billing.manage": "Керувати",
"workspace.billing.enable": "Увімкнути оплату",
"workspace.monthlyLimit.title": "Місячний ліміт",
"workspace.monthlyLimit.subtitle": "Встановіть місячний ліміт використання для облікового запису.",
"workspace.monthlyLimit.placeholder": "50",
"workspace.monthlyLimit.setting": "Встановлення...",
"workspace.monthlyLimit.set": "Встановити",
"workspace.monthlyLimit.edit": "Редагувати ліміт",
"workspace.monthlyLimit.noLimit": "Ліміт використання не встановлено.",
"workspace.monthlyLimit.currentUsage.beforeMonth": "Поточне використання за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "становить $",
"workspace.redeem.title": "Активувати купон",
"workspace.redeem.subtitle": "Активуйте код купона для отримання коштів або бонусів.",
"workspace.redeem.placeholder": "Введіть код купона",
"workspace.redeem.redeem": "Активувати",
"workspace.redeem.redeeming": "Активація...",
"workspace.redeem.success": "Купон успішно активовано.",
"workspace.reload.title": "Автоматичне поповнення",
"workspace.reload.disabled.before": "Автоматичне поповнення",
"workspace.reload.disabled.state": "вимкнено",
"workspace.reload.disabled.after": "Увімкніть для автоматичного поповнення при низькому балансі.",
"workspace.reload.enabled.before": "Автоматичне поповнення",
"workspace.reload.enabled.state": "увімкнено",
"workspace.reload.enabled.middle": "Ми поповнимо",
"workspace.reload.processingFee": "комісія за обробку",
"workspace.reload.enabled.after": "коли баланс досягне",
"workspace.reload.edit": "Редагувати",
"workspace.reload.enable": "Увімкнути",
"workspace.reload.enableAutoReload": "Увімкнути автоматичне поповнення",
"workspace.reload.reloadAmount": "Поповнити на $",
"workspace.reload.whenBalanceReaches": "Коли баланс досягне $",
"workspace.reload.saving": "Збереження...",
"workspace.reload.save": "Зберегти",
"workspace.reload.failedAt": "Поповнення не вдалося о",
"workspace.reload.reason": "Причина:",
"workspace.reload.updatePaymentMethod": "Оновіть спосіб оплати та спробуйте ще раз.",
"workspace.reload.retrying": "Повтор...",
"workspace.reload.retry": "Повторити",
"workspace.reload.error.paymentFailed": "Платіж не вдався.",
"workspace.payments.title": "Історія платежів",
"workspace.payments.subtitle": "Останні платіжні транзакції.",
"workspace.payments.table.date": "Дата",
"workspace.payments.table.paymentId": "ID платежу",
"workspace.payments.table.amount": "Сума",
"workspace.payments.table.receipt": "Квитанція",
"workspace.payments.type.credit": "кредит",
"workspace.payments.type.subscription": "підписка",
"workspace.payments.view": "Переглянути",
"workspace.black.loading": "Завантаження...",
"workspace.black.time.day": "день",
"workspace.black.time.days": "дні",
"workspace.black.time.hour": "година",
"workspace.black.time.hours": "годин(и)",
"workspace.black.time.minute": "хвилина",
"workspace.black.time.minutes": "хвилин(и)",
"workspace.black.time.fewSeconds": "кілька секунд",
"workspace.black.subscription.title": "Підписка",
"workspace.black.subscription.message": "Ви підписані на OpenCode Black за ${{plan}} на місяць.",
"workspace.black.subscription.manage": "Керувати підпискою",
"workspace.black.subscription.rollingUsage": "Використання (5 год)",
"workspace.black.subscription.weeklyUsage": "Тижневе використання",
"workspace.black.subscription.resetsIn": "Скидається через",
"workspace.black.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.black.waitlist.title": "Список очікування",
"workspace.black.waitlist.joined": "Ви в списку очікування на план OpenCode Black за ${{plan}} на місяць.",
"workspace.black.waitlist.ready": "Ми готові зареєструвати вас на план OpenCode Black за ${{plan}} на місяць.",
"workspace.black.waitlist.leave": "Залишити список очікування",
"workspace.black.waitlist.leaving": "Вихід...",
"workspace.black.waitlist.left": "Вишли",
"workspace.black.waitlist.enroll": "Зареєструватися",
"workspace.black.waitlist.enrolling": "Реєстрація...",
"workspace.black.waitlist.enrolled": "Зареєстровано",
"workspace.black.waitlist.enrollNote":
"Після натискання «Зареєструватися» підписка почнеться негайно, а картку буде списано.",
"workspace.lite.loading": "Завантаження...",
"workspace.lite.time.day": "день",
"workspace.lite.time.days": "дні",
"workspace.lite.time.hour": "година",
"workspace.lite.time.hours": "годин(и)",
"workspace.lite.time.minute": "хвилина",
"workspace.lite.time.minutes": "хвилин(и)",
"workspace.lite.time.fewSeconds": "кілька секунд",
"workspace.lite.subscription.message": "Ви підписані на OpenCode Go.",
"workspace.lite.subscription.manage": "Керувати підпискою",
"workspace.lite.subscription.rollingUsage": "Ковзне використання",
"workspace.lite.subscription.weeklyUsage": "Тижневе використання",
"workspace.lite.subscription.monthlyUsage": "Місячне використання",
"workspace.lite.subscription.resetsIn": "Скидається через",
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
"workspace.lite.black.message":
"Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.",
"workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.",
"workspace.lite.promo.description": "OpenCode Go починається від {{price}}, потім $10/місяць, із щедрими лімітами.",
"workspace.lite.promo.price": "$5 за перший місяць",
"workspace.lite.promo.modelsTitle": "Що включено",
"workspace.lite.promo.footer": "План призначений для міжнародних користувачів. Ціни можуть змінюватися.",
"workspace.lite.promo.subscribe": "Підписатися на Go",
"workspace.lite.promo.subscribing": "Перенаправлення...",
"workspace.lite.promo.otherMethods": "Інші способи оплати",
"workspace.lite.promo.selectMethod": "Виберіть спосіб оплати",
"download.title": "OpenCode | Завантажити",
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
"download.hero.title": "Завантажити OpenCode",
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
"download.hero.button": "Завантажити для {{os}}",
"download.section.terminal": "Термінал OpenCode",
"download.section.desktop": "Десктоп OpenCode (Бета)",
"download.section.extensions": "Розширення OpenCode",
"download.section.integrations": "Інтеграції OpenCode",
"download.action.download": "Завантажити",
"download.action.install": "Встановити",
"download.platform.macosAppleSilicon": "macOS (Apple Silicon)",
"download.platform.macosIntel": "macOS (Intel)",
"download.platform.windowsX64": "Windows (x64)",
"download.platform.linuxDeb": "Linux (.deb)",
"download.platform.linuxRpm": "Linux (.rpm)",
"download.faq.a3.beforeLocal":
"Не обов'язково, але ймовірно. Вам знадобиться AI-підписка, якщо ви хочете підключити платного провайдера, хоча ви можете працювати з",
"download.faq.a3.localLink": "локальними моделями",
"download.faq.a3.afterLocal.beforeZen": "безкоштовно. Хоча ми рекомендуємо",
"download.faq.a3.afterZen":
", OpenCode працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
"download.faq.a5.p1": "OpenCode є 100% безкоштовним.",
"download.faq.a5.p2.beforeZen":
"Будь-які додаткові витрати будуть з вашої підписки у провайдера моделі. Ми рекомендуємо",
"download.faq.a5.p2.afterZen": ".",
"download.faq.a6.p1": "Ваші дані зберігаються лише при створенні посилань для обміну в OpenCode.",
"download.faq.a6.p2.beforeShare": "Дізнайтеся більше про",
"download.faq.a6.shareLink": "сторінки обміну",
"enterprise.title": "OpenCode | Enterprise-рішення для вашої організації",
"enterprise.meta.description": "Зв'яжіться з OpenCode для Enterprise-рішень",
"enterprise.hero.title": "Ваш код належить вам",
"enterprise.hero.body1":
"OpenCode працює безпечно всередині вашої організації без зберігання даних, ліцензійних обмежень. Почніть пробний період із командою, потім розгорніть через SSO та внутрішній AI-шлюз.",
"enterprise.hero.body2": "Дайте знати, чим ми можемо допомогти.",
"enterprise.form.name.label": "Повне ім'я",
"enterprise.form.name.placeholder": "Джеф Безос",
"enterprise.form.role.label": "Посада",
"enterprise.form.role.placeholder": "Голова правління",
"enterprise.form.company.label": "Компанія",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Робоча електронна адреса",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Номер телефону",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Яку проблему ви намагаєтеся вирішити?",
"enterprise.form.message.placeholder": "Нам потрібна допомога з...",
"enterprise.form.send": "Надіслати",
"enterprise.form.sending": "Надсилання...",
"enterprise.form.success": "Повідомлення надіслано, ми зв'яжемося найближчим часом.",
"enterprise.form.success.submitted": "Форму успішно надіслано.",
"enterprise.form.error.allFieldsRequired": "Усі поля обов'язкові.",
"enterprise.form.error.invalidEmailFormat": "Недійсний формат email.",
"enterprise.form.error.internalServer": "Внутрішня помилка сервера.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "Що таке OpenCode Enterprise?",
"enterprise.faq.a1":
"OpenCode Enterprise для організацій, які хочуть гарантувати, що код і дані ніколи не залишають їхню інфраструктуру.",
"enterprise.faq.q2": "Як почати з OpenCode Enterprise?",
"enterprise.faq.a2":
"Почніть із внутрішнього тестування з командою. OpenCode за замовчуванням не зберігає код. Потім зв'яжіться з нами для обговорення цін.",
"enterprise.faq.q3": "Як працює ціноутворення enterprise?",
"enterprise.faq.a3":
"Ми пропонуємо ціну за робоче місце. Якщо у вас власний LLM-шлюз, ми не стягуємо плату за токени.",
"enterprise.faq.q4": "Чи безпечні мої дані з OpenCode Enterprise?",
"enterprise.faq.a4":
"Так. OpenCode не зберігає ваш код або контекст. Вся обробка відбувається локально або через прямі API-виклики.",
"brand.title": "OpenCode | Бренд",
"brand.meta.description": "Рекомендації щодо бренду OpenCode",
"brand.heading": "Рекомендації щодо бренду",
"brand.subtitle": "Ресурси та матеріали для роботи з брендом OpenCode.",
"brand.downloadAll": "Завантажити всі матеріали",
"changelog.title": "OpenCode | Журнал змін",
"changelog.meta.description": "Нотатки про випуски та журнал змін OpenCode",
"changelog.hero.title": "Журнал змін",
"changelog.hero.subtitle": "Нові оновлення та покращення OpenCode",
"changelog.empty": "Записів у журналі змін не знайдено.",
"changelog.viewJson": "Переглянути JSON",
"bench.list.title": "Бенчмарк",
"bench.list.heading": "Бенчмарки",
"bench.list.table.agent": "Агент",
"bench.list.table.model": "Модель",
"bench.list.table.score": "Результат",
"bench.submission.error.allFieldsRequired": "Усі поля обов'язкові.",
"bench.detail.title": "Бенчмарк — {{task}}",
"bench.detail.notFound": "Завдання не знайдено",
"bench.detail.na": "Н/Д",
"bench.detail.labels.agent": "Агент",
"bench.detail.labels.model": "Модель",
"bench.detail.labels.task": "Завдання",
"bench.detail.labels.repo": "Репозиторій",
"bench.detail.labels.from": "Від",
"bench.detail.labels.to": "До",
"bench.detail.labels.prompt": "Prompt",
"bench.detail.labels.commit": "Коміт",
"bench.detail.labels.averageDuration": "Середня тривалість",
"bench.detail.labels.averageScore": "Середній результат",
"bench.detail.labels.averageCost": "Середня вартість",
"bench.detail.labels.summary": "Підсумок",
"bench.detail.labels.runs": "Запуски",
"bench.detail.labels.score": "Результат",
"bench.detail.labels.base": "База",
"bench.detail.labels.penalty": "Штраф",
"bench.detail.labels.weight": "вага",
"bench.detail.table.run": "Запуск",
"bench.detail.table.score": "Результат (База — Штраф)",
"bench.detail.table.cost": "Вартість",
"bench.detail.table.duration": "Тривалість",
"bench.detail.run.title": "Запуск {{n}}",
"bench.detail.rawJson": "Сирий JSON",
}
+7
View File
@@ -11,6 +11,7 @@ export const LOCALES = [
"ja", "ja",
"pl", "pl",
"ru", "ru",
"uk",
"ar", "ar",
"no", "no",
"br", "br",
@@ -41,6 +42,7 @@ const LABEL = {
ja: "日本語", ja: "日本語",
pl: "Polski", pl: "Polski",
ru: "Русский", ru: "Русский",
uk: "Українська",
ar: "العربية", ar: "العربية",
no: "Norsk", no: "Norsk",
br: "Português (Brasil)", br: "Português (Brasil)",
@@ -61,6 +63,7 @@ const TAG = {
ja: "ja", ja: "ja",
pl: "pl", pl: "pl",
ru: "ru", ru: "ru",
uk: "uk",
ar: "ar", ar: "ar",
no: "no", no: "no",
br: "pt-BR", br: "pt-BR",
@@ -81,6 +84,7 @@ const DOCS = {
ja: "ja", ja: "ja",
pl: "pl", pl: "pl",
ru: "ru", ru: "ru",
uk: "uk",
ar: "ar", ar: "ar",
no: "nb", no: "nb",
br: "pt-br", br: "pt-br",
@@ -104,6 +108,7 @@ const DOCS_SEGMENT = new Set([
"ru", "ru",
"th", "th",
"tr", "tr",
"uk",
"zh-cn", "zh-cn",
"zh-tw", "zh-tw",
]) ])
@@ -124,6 +129,7 @@ const DOCS_LOCALE = {
ru: "ru", ru: "ru",
th: "th", th: "th",
tr: "tr", tr: "tr",
uk: "uk",
"zh-cn": "zh", "zh-cn": "zh",
"zh-tw": "zht", "zh-tw": "zht",
} as const satisfies Record<string, Locale> } as const satisfies Record<string, Locale>
@@ -239,6 +245,7 @@ function match(input: string): Locale | null {
if (value.startsWith("ja")) return "ja" if (value.startsWith("ja")) return "ja"
if (value.startsWith("pl")) return "pl" if (value.startsWith("pl")) return "pl"
if (value.startsWith("ru")) return "ru" if (value.startsWith("ru")) return "ru"
if (value.startsWith("uk")) return "uk"
if (value.startsWith("ar")) return "ar" if (value.startsWith("ar")) return "ar"
if (value.startsWith("tr")) return "tr" if (value.startsWith("tr")) return "tr"
if (value.startsWith("th")) return "th" if (value.startsWith("th")) return "th"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.4", "version": "1.15.5",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.4", "version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.4", "version": "1.15.5",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4", "version": "1.15.5",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
@@ -11,6 +11,7 @@ import { dict as desktopDa } from "./da"
import { dict as desktopJa } from "./ja" import { dict as desktopJa } from "./ja"
import { dict as desktopPl } from "./pl" import { dict as desktopPl } from "./pl"
import { dict as desktopRu } from "./ru" import { dict as desktopRu } from "./ru"
import { dict as desktopUk } from "./uk"
import { dict as desktopAr } from "./ar" import { dict as desktopAr } from "./ar"
import { dict as desktopNo } from "./no" import { dict as desktopNo } from "./no"
import { dict as desktopBr } from "./br" import { dict as desktopBr } from "./br"
@@ -27,6 +28,7 @@ import { dict as appDa } from "../../../../app/src/i18n/da"
import { dict as appJa } from "../../../../app/src/i18n/ja" import { dict as appJa } from "../../../../app/src/i18n/ja"
import { dict as appPl } from "../../../../app/src/i18n/pl" import { dict as appPl } from "../../../../app/src/i18n/pl"
import { dict as appRu } from "../../../../app/src/i18n/ru" import { dict as appRu } from "../../../../app/src/i18n/ru"
import { dict as appUk } from "../../../../app/src/i18n/uk"
import { dict as appAr } from "../../../../app/src/i18n/ar" import { dict as appAr } from "../../../../app/src/i18n/ar"
import { dict as appNo } from "../../../../app/src/i18n/no" import { dict as appNo } from "../../../../app/src/i18n/no"
import { dict as appBr } from "../../../../app/src/i18n/br" import { dict as appBr } from "../../../../app/src/i18n/br"
@@ -44,6 +46,7 @@ export type Locale =
| "ja" | "ja"
| "pl" | "pl"
| "ru" | "ru"
| "uk"
| "ar" | "ar"
| "no" | "no"
| "br" | "br"
@@ -64,6 +67,7 @@ const LOCALES: readonly Locale[] = [
"ja", "ja",
"pl", "pl",
"ru", "ru",
"uk",
"bs", "bs",
"ar", "ar",
"no", "no",
@@ -89,6 +93,7 @@ function detectLocale(): Locale {
if (language.toLowerCase().startsWith("ja")) return "ja" if (language.toLowerCase().startsWith("ja")) return "ja"
if (language.toLowerCase().startsWith("pl")) return "pl" if (language.toLowerCase().startsWith("pl")) return "pl"
if (language.toLowerCase().startsWith("ru")) return "ru" if (language.toLowerCase().startsWith("ru")) return "ru"
if (language.toLowerCase().startsWith("uk")) return "uk"
if (language.toLowerCase().startsWith("ar")) return "ar" if (language.toLowerCase().startsWith("ar")) return "ar"
if ( if (
language.toLowerCase().startsWith("no") || language.toLowerCase().startsWith("no") ||
@@ -148,6 +153,7 @@ function build(locale: Locale): Dictionary {
if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) } if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) }
if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) } if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) }
if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) } if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) }
if (locale === "uk") return { ...base, ...i18n.flatten(appUk), ...i18n.flatten(desktopUk) }
if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) } if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) }
if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) } if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) }
if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) } if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) }
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Перевірити оновлення...",
"desktop.menu.installCli": "Встановити CLI...",
"desktop.menu.reloadWebview": "Перезавантажити Webview",
"desktop.menu.restart": "Перезапустити",
"desktop.dialog.chooseFolder": "Виберіть теку",
"desktop.dialog.chooseFile": "Виберіть файл",
"desktop.dialog.saveFile": "Зберегти файл",
"desktop.updater.checkFailed.title": "Не вдалося перевірити оновлення",
"desktop.updater.checkFailed.message": "Не вдалося перевірити наявність оновлень",
"desktop.updater.none.title": "Немає доступних оновлень",
"desktop.updater.none.message": "Ви вже використовуєте найновішу версію OpenCode",
"desktop.updater.downloadFailed.title": "Помилка оновлення",
"desktop.updater.downloadFailed.message": "Не вдалося завантажити оновлення",
"desktop.updater.downloaded.title": "Оновлення завантажено",
"desktop.updater.downloaded.prompt":
"Версію {{version}} OpenCode завантажено. Бажаєте встановити її та перезапустити?",
"desktop.updater.installFailed.title": "Помилка оновлення",
"desktop.updater.installFailed.message": "Не вдалося встановити оновлення",
"desktop.cli.installed.title": "CLI встановлено",
"desktop.cli.installed.message":
"CLI встановлено до {{path}}\n\nПерезапустіть термінал, щоб використовувати команду 'opencode'.",
"desktop.cli.failed.title": "Не вдалося встановити",
"desktop.cli.failed.message": "Не вдалося встановити CLI: {{error}}",
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.4", "version": "1.15.5",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.15.4" version = "1.15.5"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.4", "version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4", "version": "1.15.5",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4", "version": "1.15.5",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2
View File
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"), type: Schema.tag("text-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" }) }).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta> export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"), type: Schema.tag("reasoning-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta> export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4", "version": "1.15.5",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -39,8 +39,9 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -101,6 +102,7 @@
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -29,6 +29,8 @@ export type OpenTuiKeymap = ReturnType<typeof useKeymap>
const KEY_ALIASES = { const KEY_ALIASES = {
enter: "return", enter: "return",
esc: "escape", esc: "escape",
pgdown: "pagedown",
pgup: "pageup",
} as const } as const
function expandKeyAliases(input: string) { function expandKeyAliases(input: string) {
@@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {} }) {}
@@ -1,5 +1,127 @@
import { Schema } from "effect" import { Schema } from "effect"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
{
message: Schema.String,
kind: Schema.optional(Schema.String),
field: Schema.optional(Schema.String),
},
{ httpApiStatus: 400 },
) {}
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
"ForbiddenError",
{ message: Schema.String },
{ httpApiStatus: 403 },
) {}
export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
"ConflictError",
{
message: Schema.String,
resource: Schema.optional(Schema.String),
},
{ httpApiStatus: 409 },
) {}
export class UpstreamError extends Schema.TaggedErrorClass<UpstreamError>()(
"UpstreamError",
{
message: Schema.String,
service: Schema.optional(Schema.String),
status: Schema.optional(Schema.Number),
},
{ httpApiStatus: 502 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
message: Schema.String,
service: Schema.optional(Schema.String),
},
{ httpApiStatus: 503 },
) {}
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()(
"TimeoutError",
{
message: Schema.String,
operation: Schema.optional(Schema.String),
},
{ httpApiStatus: 504 },
) {}
export class UnknownError extends Schema.TaggedErrorClass<UnknownError>()(
"UnknownError",
{
message: Schema.String,
ref: Schema.optional(Schema.String),
},
{ httpApiStatus: 500 },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"ProviderNotFoundError",
{
providerID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()(
"ModelNotFoundError",
{
providerID: Schema.String,
modelID: Schema.String,
suggestions: Schema.Array(Schema.String),
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
"MessageNotFoundError",
{
sessionID: Schema.String,
messageID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()(
"SessionBusyError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 409 },
) {}
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")( export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
{ {
name: Schema.Literal("NotFoundError"), name: Schema.Literal("NotFoundError"),
@@ -99,17 +99,13 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
applyLegacySchemaOverrides(spec) applyLegacySchemaOverrides(spec)
normalizeComponentDescriptions(spec) normalizeComponentDescriptions(spec)
addLegacyErrorSchemas(spec) addLegacyErrorSchemas(spec)
delete spec.components?.schemas?.Unauthorized
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
delete spec.components?.schemas?.effect_HttpApiError_BadRequest
delete spec.components?.schemas?.effect_HttpApiError_NotFound
delete spec.components?.securitySchemes delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) { for (const [path, item] of Object.entries(spec.paths ?? {})) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) { for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method] const operation = item[method]
if (!operation) continue if (!operation) continue
const isV2Api = isV2ApiPath(path)
if (operation.requestBody) { if (operation.requestBody) {
// The legacy OpenAPI surface never marked request bodies as required. // The legacy OpenAPI surface never marked request bodies as required.
// Keep that SDK surface stable while the HttpApi spec is tightened. // Keep that SDK surface stable while the HttpApi spec is tightened.
@@ -146,11 +142,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema)) if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
} }
} }
// Auth is still runtime middleware outside the public OpenAPI metadata, so if (!isV2Api) {
// the SDK should not expose auth schemes or generated 401 error unions. // Auth is still runtime middleware outside the legacy public OpenAPI
delete operation.security // metadata, so the legacy SDK should not expose auth schemes or
delete operation.responses?.["401"] // generated 401 error unions.
normalizeLegacyErrorResponses(operation) delete operation.security
delete operation.responses?.["401"]
normalizeLegacyErrorResponses(operation)
}
normalizeLegacyOperation(operation, path, method) normalizeLegacyOperation(operation, path, method)
if ((path === "/event" || path === "/global/event") && method === "get") { if ((path === "/event" || path === "/global/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are // HttpApi has no first-class SSE response schema, and these handlers are
@@ -171,9 +170,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
for (const param of operation.parameters ?? []) normalizeParameter(param, route) for (const param of operation.parameters ?? []) normalizeParameter(param, route)
} }
} }
deleteUnusedLegacyErrorComponents(spec)
return input return input
} }
function isV2ApiPath(path: string) {
return path === "/api" || path.startsWith("/api/")
}
function addLegacyErrorSchemas(spec: OpenApiSpec) { function addLegacyErrorSchemas(spec: OpenApiSpec) {
if (!spec.components?.schemas) return if (!spec.components?.schemas) return
spec.components.schemas.BadRequestError = { spec.components.schemas.BadRequestError = {
@@ -345,6 +349,26 @@ function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
} }
} }
function deleteUnusedLegacyErrorComponents(spec: OpenApiSpec) {
for (const name of [
"Unauthorized",
"EffectHttpApiErrorBadRequest",
"EffectHttpApiErrorNotFound",
"effect_HttpApiError_BadRequest",
"effect_HttpApiError_NotFound",
]) {
if (referencesComponent(spec.paths, name)) continue
delete spec.components?.schemas?.[name]
}
}
function referencesComponent(input: unknown, name: string): boolean {
if (Array.isArray(input)) return input.some((item) => referencesComponent(item, name))
if (!input || typeof input !== "object") return false
if ((input as OpenApiSchema).$ref === `#/components/schemas/${name}`) return true
return Object.values(input).some((value) => referencesComponent(value, name))
}
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) { function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"] if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"] if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
+156 -83
View File
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect" import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
import type { LLMEvent } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import type { LLMClientService } from "@opencode-ai/llm/route"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import * as Option from "effect/Option" import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { LLMAISDK } from "./llm/ai-sdk"
import { LLMNativeRuntime } from "./llm/native-runtime"
const log = Log.create({ service: "llm" }) const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. // Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> => const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
abort: AbortSignal abort: AbortSignal
} }
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface { export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown> readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
const live: Layer.Layer< const live: Layer.Layer<
Service, Service,
never, never,
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service | Auth.Service
| Config.Service
| Provider.Service
| Plugin.Service
| Permission.Service
| LLMClientService
| RuntimeFlags.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -71,6 +79,7 @@ const live: Layer.Layer<
const provider = yield* Provider.Service const provider = yield* Provider.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const perm = yield* Permission.Service const perm = yield* Permission.Service
const llmClient = yield* LLMClient.Service
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
@@ -202,7 +211,7 @@ const live: Layer.Layer<
Object.keys(tools).length === 0 && Object.keys(tools).length === 0 &&
hasToolCalls(input.messages) hasToolCalls(input.messages)
) { ) {
tools["_noop"] = tool({ tools["_noop"] = aiTool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: "object", type: "object",
@@ -322,86 +331,141 @@ const live: Layer.Layer<
? (yield* InstanceState.context).project.id ? (yield* InstanceState.context).project.id
: undefined : undefined
return streamText({ const requestHeaders = {
onError(error) { ...(input.model.providerID.startsWith("opencode")
l.error("stream error", { ? {
error, ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
}) "x-opencode-session": input.sessionID,
}, "x-opencode-request": input.user.id,
async experimental_repairToolCall(failed) { "x-opencode-client": flags.client,
const lower = failed.toolCall.toolName.toLowerCase() "User-Agent": `opencode/${InstallationVersion}`,
if (lower !== failed.toolCall.toolName && sortedTools[lower]) { }
l.info("repairing tool call", { : {
tool: failed.toolCall.toolName, "x-session-affinity": input.sessionID,
repaired: lower, ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
}),
...input.model.headers,
...headers,
}
if (flags.experimentalNativeLlm) {
const native = LLMNativeRuntime.stream({
model: input.model,
provider: item,
auth: info,
llmClient,
isOpenaiOauth,
system,
messages,
tools: sortedTools,
toolChoice: input.toolChoice,
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
maxOutputTokens: params.maxOutputTokens,
providerOptions: params.options,
headers: requestHeaders,
abort: input.abort,
})
if (native.type === "supported") {
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "native",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
}),
)
return {
type: "native" as const,
stream: native.stream,
}
}
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
"llm.native_unsupported_reason": native.reason,
}),
)
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
}
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
}),
)
return {
type: "ai-sdk" as const,
result: streamText({
onError(error) {
l.error("stream error", {
error,
}) })
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return { return {
...failed.toolCall, ...failed.toolCall,
toolName: lower, input: JSON.stringify({
} tool: failed.toolCall.toolName,
} error: failed.error.message,
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": opencodeProjectID,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": flags.client,
"User-Agent": `opencode/${InstallationVersion}`,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
}), }),
...input.model.headers, toolName: "invalid",
...headers, }
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
}, },
}, temperature: params.temperature,
}) topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: requestHeaders,
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
}),
}
}) })
const stream: Interface["stream"] = (input) => const stream: Interface["stream"] = (input) =>
@@ -415,7 +479,15 @@ const live: Layer.Layer<
const result = yield* run({ ...input, abort: ctrl.signal }) const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))) if (result.type === "native") return result.stream
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)
}), }),
), ),
) )
@@ -432,6 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer),
), ),
) )
@@ -0,0 +1,67 @@
# Session LLM Runtime Boundaries
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
This folder contains adapters behind that service boundary:
- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path.
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
## Runtime selection
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
```txt
╭───────────────────╮
╭───────────────────────────▶│ session processor │
│ ╰─────────┬─────────╯
│ │
│ │
│ │
│ ▼
│ ╭─────────────────────────╮
│ │ LLM.Service (../llm.ts) │
│ ╰────────────┬────────────╯
│ │
│ │
│ │
│ ▼
│ ╭───────────╮
│ ╭─╯ ╰─╮
│ │ native gate │
│ ╰─╮ ╭─╯
│ ╰─────┬─────╯
│ │
│ ╭────── no ──────┴─────── yes ────────╮
│ │ │
│ ▼ ▼
│ ╭───────────────────────────╮ ╭───────────────────╮
│ │ AI SDK │ │ native-runtime.ts │
│ │ streamText / generateText │ ╰────────┬──────────╯
│ ╰─────────────┬─────────────╯ │
│ │ │
│ ╭───╯ │
│ │ │
│ ▼ ▼
│ ╭───────────────────────╮ ╭────────────────────────────╮
│ │ ai-sdk.ts │ │ native-request.ts │
│ │ fullStream → LLMEvent │ │ session input → LLMRequest │
│ ╰──────────┬────────────╯ ╰──────────────┬─────────────╯
│ │ │
│ │ ╭───╯
│ │ │
│ ▼ ▼
│ ╭─────────────────╮ ╭─────────────────────────────╮
╰───────┤ LLMEvent stream │◀────────────┤ LLMClient · RequestExecutor │
╰─────────────────╯ ╰─────────────────────────────╯
```
`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ.
Safety boundary:
- AI SDK remains the default.
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
+254
View File
@@ -0,0 +1,254 @@
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { type streamText } from "ai"
import { errorMessage } from "@/util/error"
type Result = Awaited<ReturnType<typeof streamText>>
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export function adapterState() {
return {
step: 0,
text: 0,
reasoning: 0,
currentTextID: undefined as string | undefined,
currentReasoningID: undefined as string | undefined,
toolNames: {} as Record<string, string>,
}
}
function finishReason(value: string | undefined): FinishReason {
return Schema.is(FinishReason)(value) ? value : "unknown"
}
function providerMetadata(value: unknown): ProviderMetadata | undefined {
if (value == null) return undefined
return Schema.is(ProviderMetadata)(value) ? value : undefined
}
function usage(value: unknown) {
if (!value || typeof value !== "object") return undefined
const item = value as {
inputTokens?: number
outputTokens?: number
totalTokens?: number
reasoningTokens?: number
cachedInputTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
}
const entries = Object.entries({
inputTokens: item.inputTokens,
outputTokens: item.outputTokens,
totalTokens: item.totalTokens,
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
}).filter((entry) => entry[1] !== undefined)
return entries.length === 0 ? undefined : Object.fromEntries(entries)
}
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
return state.currentTextID
}
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
return state.currentReasoningID
}
export function toLLMEvents(
state: ReturnType<typeof adapterState>,
event: AISDKEvent,
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
switch (event.type) {
case "start":
return Effect.succeed([])
case "start-step":
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
case "finish-step":
return Effect.sync(() => [
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "finish":
return Effect.sync(() => {
const events = [
LLMEvent.finish({
reason: finishReason(event.finishReason),
usage: usage(event.totalUsage),
providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,
}),
]
// Reset so the adapter can be reused for a follow-up stream without leaking
// counters or block IDs. adapterState() is the single source of truth for shape.
Object.assign(state, adapterState())
return events
})
case "text-start":
return Effect.sync(() => {
state.currentTextID = currentTextID(state, event.id)
return [
LLMEvent.textStart({
id: state.currentTextID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "text-delta":
return Effect.succeed([
LLMEvent.textDelta({
id: currentTextID(state, event.id),
text: event.text,
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "text-end":
return Effect.sync(() => {
const id = currentTextID(state, event.id)
state.currentTextID = undefined
return [
LLMEvent.textEnd({
id,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "reasoning-start":
return Effect.sync(() => {
state.currentReasoningID = currentReasoningID(state, event.id)
return [
LLMEvent.reasoningStart({
id: state.currentReasoningID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "reasoning-delta":
return Effect.succeed([
LLMEvent.reasoningDelta({
id: currentReasoningID(state, event.id),
text: event.text,
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "reasoning-end":
return Effect.sync(() => {
const id = currentReasoningID(state, event.id)
state.currentReasoningID = undefined
return [
LLMEvent.reasoningEnd({
id,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-start":
return Effect.sync(() => {
state.toolNames[event.id] = event.toolName
return [
LLMEvent.toolInputStart({
id: event.id,
name: event.toolName,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-delta":
return Effect.succeed([
LLMEvent.toolInputDelta({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
text: event.delta ?? "",
}),
])
case "tool-input-end":
return Effect.succeed([
LLMEvent.toolInputEnd({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "tool-call":
return Effect.sync(() => {
state.toolNames[event.toolCallId] = event.toolName
return [
LLMEvent.toolCall({
id: event.toolCallId,
name: event.toolName,
input: event.input,
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-result":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? "unknown"
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolResult({
id: event.toolCallId,
name,
result: ToolResultValue.make(event.output),
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-error":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolError({
id: event.toolCallId,
name,
message: errorMessage(event.error),
error: event.error,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "error":
return Effect.fail(event.error)
case "abort":
case "source":
case "file":
case "raw":
case "tool-output-denied":
case "tool-approval-request":
return Effect.succeed([])
default: {
const _exhaustive: never = event
void _exhaustive
return Effect.succeed([])
}
}
}
export * as LLMAISDK from "./ai-sdk"
@@ -0,0 +1,188 @@
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
import "@opencode-ai/llm/providers"
import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider"
import { isRecord } from "@/util/record"
type ToolInput = {
readonly description?: string
readonly inputSchema?: unknown
}
export type RequestInput = {
readonly model: Provider.Model
readonly apiKey?: string
readonly baseURL?: string
readonly system?: readonly string[]
readonly messages: readonly ModelMessage[]
readonly tools?: Record<string, ToolInput>
readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
readonly topP?: number
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: LLMRequest["providerOptions"]
readonly headers?: Record<string, string>
}
const DEFAULT_BASE_URL: Record<string, string> = {
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
}
const ROUTE: Record<string, string> = {
"@ai-sdk/openai": "openai-responses",
"@ai-sdk/azure": "azure-openai-responses",
"@ai-sdk/anthropic": "anthropic-messages",
"@ai-sdk/google": "gemini",
"@ai-sdk/amazon-bedrock": "bedrock-converse",
"@ai-sdk/openai-compatible": "openai-compatible-chat",
"@openrouter/ai-sdk-provider": "openrouter",
}
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
if (!isRecord(value)) return undefined
const result = Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
)
return Object.keys(result).length === 0 ? undefined : result
}
const textPart = (part: Record<string, unknown>) => ({
type: "text" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: providerMetadata(part.providerOptions),
})
const mediaPart = (part: Record<string, unknown>) => {
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
return {
type: "media" as const,
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
data: part.data,
filename: typeof part.filename === "string" ? part.filename : undefined,
}
}
const toolResult = (part: Record<string, unknown>) => {
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
return ToolResultPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
result: "value" in output ? output.value : output,
resultType: type,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: providerMetadata(part.providerOptions),
})
}
const contentPart = (part: unknown) => {
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
if (part.type === "text") return textPart(part)
if (part.type === "file") return mediaPart(part)
if (part.type === "reasoning")
return {
type: "reasoning" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: providerMetadata(part.providerOptions),
}
if (part.type === "tool-call")
return ToolCallPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
input: part.input,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: providerMetadata(part.providerOptions),
})
if (part.type === "tool-result") return toolResult(part)
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
}
const content = (value: ModelMessage["content"]) =>
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
const messages = (input: readonly ModelMessage[]) => {
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
const messages = input.flatMap((message) => {
if (message.role === "system") return []
return [
Message.make({
role: message.role,
content: content(message.content),
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
}),
]
})
return { system, messages }
}
const schema = (value: unknown): JsonSchema => {
if (!isRecord(value)) return { type: "object", properties: {} }
if (isRecord(value.jsonSchema)) return value.jsonSchema
return value
}
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
Object.entries(input ?? {}).map(([name, item]) =>
ToolDefinition.make({
name,
description: item.description ?? "",
inputSchema: schema(item.inputSchema),
}),
)
const generation = (input: RequestInput) => {
const result = {
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxTokens: input.maxOutputTokens,
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
const baseURL = (model: Provider.Model) => {
if (model.api.url) return model.api.url
const fallback = DEFAULT_BASE_URL[model.api.npm]
if (fallback) return fallback
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
}
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
const model = "model" in input ? input.model : input
const route = ROUTE[model.api.npm]
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
return LLM.model({
id: model.api.id,
provider: model.providerID,
route,
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
apiKey: "model" in input ? input.apiKey : undefined,
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
limits: {
context: model.limit.context,
output: model.limit.output,
},
})
}
export const request = (input: RequestInput) => {
const converted = messages(input.messages)
return LLM.request({
model: model(input, input.headers),
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
messages: converted.messages,
tools: tools(input.tools),
toolChoice: input.toolChoice,
generation: generation(input),
providerOptions: input.providerOptions,
})
}
export * as LLMNative from "./native-request"
@@ -0,0 +1,122 @@
import type { Auth } from "@/auth"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { asSchema, type ModelMessage, type Tool } from "ai"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import type { LLMClientShape } from "@opencode-ai/llm/route"
import { LLMNative } from "./native-request"
export type RuntimeStatus =
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
| { readonly type: "unsupported"; readonly reason: string }
export type StreamResult =
| { readonly type: "supported"; readonly stream: Stream.Stream<LLMEvent, unknown> }
| { readonly type: "unsupported"; readonly reason: string }
type StreamInput = {
readonly model: Provider.Model
readonly provider: Provider.Info
readonly auth: Auth.Info | undefined
readonly llmClient: LLMClientShape
readonly isOpenaiOauth: boolean
readonly system: string[]
readonly messages: ModelMessage[]
readonly tools: Record<string, Tool>
readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
readonly topP?: number
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: Record<string, any>
readonly headers: Record<string, string>
readonly abort: AbortSignal
}
export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI or Anthropic" }
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }
return {
type: "supported",
apiKey,
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
}
}
export function stream(input: StreamInput): StreamResult {
const current = status(input)
if (current.type === "unsupported") return current
return {
...current,
stream: input.llmClient.stream({
request: LLMNative.request({
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
system: input.isOpenaiOauth ? input.system : [],
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
toolChoice: input.toolChoice,
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxOutputTokens: input.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
}),
tools: nativeTools(input.tools, input),
}),
}
}
function providerHeaders(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
}
function nativeSchema(value: unknown): JsonSchema {
if (!value || typeof value !== "object") return { type: "object", properties: {} }
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
return value.jsonSchema as JsonSchema
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
}
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
return Object.fromEntries(
Object.entries(tools).map(([name, item]) => [
name,
nativeTool({
description: item.description ?? "",
jsonSchema: nativeSchema(item.inputSchema),
execute: (args: unknown, ctx) =>
Effect.tryPromise({
try: () => {
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
return item.execute(args, {
toolCallId: ctx?.id ?? name,
messages: input.messages,
abortSignal: input.abort,
})
},
catch: (error) => new ToolFailure({ message: errorMessage(error), error }),
}),
}),
]),
)
}
export * as LLMNativeRuntime from "./native-runtime"
@@ -627,6 +627,26 @@ function providerMeta(metadata: Record<string, any> | undefined) {
return Object.keys(rest).length > 0 ? rest : undefined return Object.keys(rest).length > 0 ? rest : undefined
} }
function fileSourceContext(part: FilePart) {
const source = part.source
if (!source) return
if (source.type === "resource") return
const filepath = source.path.trim()
if (!filepath) return
if (filepath === "clipboard" && part.filename === "clipboard") return
const name = part.filename?.trim()
const reference = source.text.value.trim()
return [
`Attached ${part.mime.startsWith("image/") ? "image" : "file"}${name ? `: ${name}` : ""}`,
`Source path: ${filepath}`,
reference ? `Prompt reference: ${reference}` : undefined,
]
.filter((item): item is string => !!item)
.join("\n")
}
export const toModelMessagesEffect = Effect.fnUntraced(function* ( export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[], input: WithParts[],
model: Provider.Model, model: Provider.Model,
@@ -707,6 +727,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}) })
// text/plain and directory files are converted into text parts, ignore them // text/plain and directory files are converted into text parts, ignore them
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
const context = fileSourceContext(part)
if (context) {
userMessage.parts.push({
type: "text",
text: context,
})
}
if (options?.stripMedia && isMedia(part.mime)) { if (options?.stripMedia && isMedia(part.mime)) {
userMessage.parts.push({ userMessage.parts.push({
type: "text", type: "text",
+166 -107
View File
@@ -1,4 +1,5 @@
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import { Image } from "@/image/image"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
@@ -9,7 +10,6 @@ import { Snapshot } from "@/snapshot"
import * as Session from "./session" import * as Session from "./session"
import { LLM } from "./llm" import { LLM } from "./llm"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Image } from "@/image/image"
import { isOverflow } from "./overflow" import { isOverflow } from "./overflow"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID } from "./schema" import type { SessionID } from "./schema"
@@ -28,14 +28,13 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
const DOOM_LOOP_THRESHOLD = 3 const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" }) const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue" export type Result = "compact" | "stop" | "continue"
export type Event = LLM.Event
export interface Handle { export interface Handle {
readonly message: MessageV2.Assistant readonly message: MessageV2.Assistant
readonly updateToolCall: ( readonly updateToolCall: (
@@ -69,6 +68,7 @@ type ToolCall = {
messageID: MessageV2.ToolPart["messageID"] messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"] sessionID: MessageV2.ToolPart["sessionID"]
done: Deferred.Deferred<void> done: Deferred.Deferred<void>
inputEnded: boolean
} }
interface ProcessorContext extends Input { interface ProcessorContext extends Input {
@@ -81,7 +81,7 @@ interface ProcessorContext extends Input {
reasoningMap: Record<string, MessageV2.ReasoningPart> reasoningMap: Record<string, MessageV2.ReasoningPart>
} }
type StreamEvent = Event type StreamEvent = LLMEvent
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
const call = ctx.toolcalls[toolCallID] const call = ctx.toolcalls[toolCallID]
if (!call) return if (!call) return undefined
const part = yield* session.getPart({ const part = yield* session.getPart({
partID: call.partID, partID: call.partID,
messageID: call.messageID, messageID: call.messageID,
@@ -145,7 +145,7 @@ export const layer = Layer.effect(
}) })
if (!part || part.type !== "tool") { if (!part || part.type !== "tool") {
delete ctx.toolcalls[toolCallID] delete ctx.toolcalls[toolCallID]
return return undefined
} }
return { call, part } return { call, part }
}) })
@@ -155,7 +155,7 @@ export const layer = Layer.effect(
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) { ) {
const match = yield* readToolCall(toolCallID) const match = yield* readToolCall(toolCallID)
if (!match) return if (!match) return undefined
const part = yield* session.updatePart(update(match.part)) const part = yield* session.updatePart(update(match.part))
ctx.toolcalls[toolCallID] = { ctx.toolcalls[toolCallID] = {
...match.call, ...match.call,
@@ -211,12 +211,98 @@ export const layer = Layer.effect(
return true return true
}) })
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
if (!(reasoningID in ctx.reasoningMap)) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
reasoningID,
text: ctx.reasoningMap[reasoningID].text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
// oxlint-disable-next-line no-self-assign -- reactivity trigger
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
yield* session.updatePart(ctx.reasoningMap[reasoningID])
delete ctx.reasoningMap[reasoningID]
})
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
id: string
name: string
providerExecuted?: boolean
}) {
const existing = yield* readToolCall(input.id)
if (existing) {
if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
const part = yield* session.updatePart({
...existing.part,
metadata: { ...existing.part.metadata, providerExecuted: true },
})
ctx.toolcalls[input.id] = {
...existing.call,
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
}
return { call: ctx.toolcalls[input.id], part }
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: ctx.sessionID,
callID: input.id,
name: input.name,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
const part = yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
tool: input.name,
callID: input.id,
state: { status: "pending", input: {}, raw: "" },
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
ctx.toolcalls[input.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
inputEnded: false,
}
return { call: ctx.toolcalls[input.id], part }
})
const isFilePart = Schema.is(MessageV2.FilePart)
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
return {
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
output: value.result.value.output,
attachments: Array.isArray(value.result.value.attachments)
? value.result.value.attachments.filter(isFilePart)
: undefined,
}
}
return {
title: value.name,
metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
output:
typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
}
}
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) { switch (value.type) {
case "start":
yield* status.set(ctx.sessionID, { type: "busy" })
return
case "reasoning-start": case "reasoning-start":
if (value.id in ctx.reasoningMap) return if (value.id in ctx.reasoningMap) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
@@ -240,6 +326,7 @@ export const layer = Layer.effect(
return return
case "reasoning-delta": case "reasoning-delta":
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
if (!(value.id in ctx.reasoningMap)) return if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
@@ -253,59 +340,26 @@ export const layer = Layer.effect(
return return
case "reasoning-end": case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return if (value.providerMetadata && value.id in ctx.reasoningMap) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. ctx.reasoningMap[value.id].metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
reasoningID: value.id,
text: ctx.reasoningMap[value.id].text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
} }
// oxlint-disable-next-line no-self-assign -- reactivity trigger yield* finishReasoning(value.id)
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id])
delete ctx.reasoningMap[value.id]
return return
case "tool-input-start": case "tool-input-start":
if (ctx.assistantMessage.summary) { if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: ctx.sessionID,
callID: value.id,
name: value.toolName,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
const part = yield* session.updatePart({
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: { status: "pending", input: {}, raw: "" },
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
ctx.toolcalls[value.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
} }
yield* ensureToolCall(value)
return return
case "tool-input-delta": case "tool-input-delta":
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
// delta fragments into `state.raw` is redundant work for no current consumer.
return return
case "tool-input-end": { case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) { if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
@@ -315,37 +369,52 @@ export const layer = Layer.effect(
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
}) })
} }
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
return return
} }
case "tool-call": { case "tool-call": {
if (ctx.assistantMessage.summary) { if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
const toolCall = yield* ensureToolCall(value)
const input = toolInput(value.input)
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
callID: value.id,
text: "",
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
} }
const toolCall = yield* readToolCall(value.toolCallId)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) { if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
callID: value.toolCallId, callID: value.id,
tool: value.toolName, tool: value.name,
input: value.input, input,
provider: { provider: {
executed: toolCall?.part.metadata?.providerExecuted === true, executed: toolCall.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
}, },
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
}) })
} }
yield* updateToolCall(value.toolCallId, (match) => ({ yield* updateToolCall(value.id, (match) => ({
...match, ...match,
tool: value.toolName, tool: value.name,
state: { state:
...match.state, match.state.status === "running"
status: "running", ? { ...match.state, input }
input: value.input, : {
time: { start: Date.now() }, status: "running",
}, input,
time: { start: Date.now() },
},
metadata: match.metadata?.providerExecuted metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true } ? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata, : value.providerMetadata,
@@ -359,9 +428,9 @@ export const layer = Layer.effect(
!recentParts.every( !recentParts.every(
(part) => (part) =>
part.type === "tool" && part.type === "tool" &&
part.tool === value.toolName && part.tool === value.name &&
part.state.status !== "pending" && part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(value.input), JSON.stringify(part.state.input) === JSON.stringify(input),
) )
) { ) {
return return
@@ -370,27 +439,19 @@ export const layer = Layer.effect(
const agent = yield* agents.get(ctx.assistantMessage.agent) const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({ yield* permission.ask({
permission: "doom_loop", permission: "doom_loop",
patterns: [value.toolName], patterns: [value.name],
sessionID: ctx.assistantMessage.sessionID, sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.toolName, input: value.input }, metadata: { tool: value.name, input },
always: [value.toolName], always: [value.name],
ruleset: agent.permission, ruleset: agent.permission,
}) })
return return
} }
case "tool-result": { case "tool-result": {
const toolCall = yield* readToolCall(value.toolCallId) const toolCall = yield* readToolCall(value.id)
const toolAttachments: MessageV2.FilePart[] = ( const rawOutput = toolResultOutput(value)
Array.isArray(value.output.attachments) ? value.output.attachments : [] const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
).filter(
(attachment: unknown): attachment is MessageV2.FilePart =>
isRecord(attachment) &&
attachment.type === "file" &&
typeof attachment.mime === "string" &&
typeof attachment.url === "string",
)
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
attachment.mime.startsWith("image/") attachment.mime.startsWith("image/")
? image.normalize(attachment).pipe( ? image.normalize(attachment).pipe(
Effect.catchIf( Effect.catchIf(
@@ -404,18 +465,18 @@ export const layer = Layer.effect(
const omitted = normalized.filter(Exit.isFailure).length const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = { const output = {
...value.output, ...rawOutput,
output: output:
omitted === 0 omitted === 0
? value.output.output ? rawOutput.output
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
attachments: attachments?.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
} }
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) { if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Success, { yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
callID: value.toolCallId, callID: value.id,
structured: output.metadata, structured: output.metadata,
content: [ content: [
{ {
@@ -423,32 +484,32 @@ export const layer = Layer.effect(
text: output.output, text: output.output,
}, },
...(output.attachments?.map((item: MessageV2.FilePart) => ({ ...(output.attachments?.map((item: MessageV2.FilePart) => ({
type: "file", type: "file" as const,
uri: item.url, uri: item.url,
mime: item.mime, mime: item.mime,
name: item.filename, name: item.filename,
})) ?? []), })) ?? []),
], ],
provider: { provider: {
executed: toolCall?.part.metadata?.providerExecuted === true, executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
}, },
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
}) })
} }
yield* completeToolCall(value.toolCallId, output) yield* completeToolCall(value.id, output)
return return
} }
case "tool-error": { case "tool-error": {
const toolCall = yield* readToolCall(value.toolCallId) const toolCall = yield* readToolCall(value.id)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) { if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
callID: value.toolCallId, callID: value.id,
error: { error: {
type: "unknown", type: "unknown",
message: errorMessage(value.error), message: value.message,
}, },
provider: { provider: {
executed: toolCall?.part.metadata?.providerExecuted === true, executed: toolCall?.part.metadata?.providerExecuted === true,
@@ -456,14 +517,14 @@ export const layer = Layer.effect(
timestamp: DateTime.makeUnsafe(Date.now()), timestamp: DateTime.makeUnsafe(Date.now()),
}) })
} }
yield* failToolCall(value.toolCallId, value.error) yield* failToolCall(value.id, value.error ?? new Error(value.message))
return return
} }
case "error": case "provider-error":
throw value.error throw new Error(value.message)
case "start-step": case "step-start":
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
if (!ctx.assistantMessage.summary) { if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
@@ -490,11 +551,12 @@ export const layer = Layer.effect(
}) })
return return
case "finish-step": { case "step-finish": {
const completedSnapshot = yield* snapshot.track() const completedSnapshot = yield* snapshot.track()
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
const usage = Session.getUsage({ const usage = Session.getUsage({
model: ctx.model, model: ctx.model,
usage: value.usage, usage: value.usage ?? new Usage({}),
metadata: value.providerMetadata, metadata: value.providerMetadata,
}) })
if (!ctx.assistantMessage.summary) { if (!ctx.assistantMessage.summary) {
@@ -502,7 +564,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) { if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
finish: value.finishReason, finish: value.reason,
cost: usage.cost, cost: usage.cost,
tokens: usage.tokens, tokens: usage.tokens,
snapshot: completedSnapshot, snapshot: completedSnapshot,
@@ -510,12 +572,12 @@ export const layer = Layer.effect(
}) })
} }
} }
ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.finish = value.reason
ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
reason: value.finishReason, reason: value.reason,
snapshot: completedSnapshot, snapshot: completedSnapshot,
messageID: ctx.assistantMessage.id, messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID, sessionID: ctx.assistantMessage.sessionID,
@@ -622,10 +684,6 @@ export const layer = Layer.effect(
case "finish": case "finish":
return return
default:
slog.info("unhandled", { event: value.type, value })
return
} }
}) })
@@ -727,6 +785,7 @@ export const layer = Layer.effect(
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
ctx.currentText = undefined ctx.currentText = undefined
ctx.reasoningMap = {} ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput) const stream = llm.stream(streamInput)
yield* stream.pipe( yield* stream.pipe(
+14 -191
View File
@@ -8,17 +8,15 @@ import * as Session from "./session"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { type Tool as AITool, tool, jsonSchema } from "ai"
import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema7 } from "@ai-sdk/provider"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { Bus } from "../bus" import { Bus } from "../bus"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Instruction } from "./instruction" import { Instruction } from "./instruction"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import MAX_STEPS from "../session/prompt/max-steps.txt" import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { ulid } from "ulid" import { ulid } from "ulid"
@@ -48,7 +46,6 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
@@ -63,6 +60,8 @@ import * as Database from "@/storage/db"
import { SessionTable } from "./session.sql" import { SessionTable } from "./session.sql"
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
import { SessionReminders } from "./reminders" import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { LLMEvent } from "@opencode-ai/llm"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -125,9 +124,6 @@ export const layer = Layer.effect(
const references = yield* Reference.Service const references = yield* Reference.Service
const events = yield* EventV2Bridge.Service const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
const runner = Effect.fn("SessionPrompt.runner")(function* () {
return yield* EffectBridge.make()
})
const ops = Effect.fn("SessionPrompt.ops")(function* () { const ops = Effect.fn("SessionPrompt.ops")(function* () {
return { return {
cancel: (sessionID: SessionID) => cancel(sessionID), cancel: (sessionID: SessionID) => cancel(sessionID),
@@ -283,7 +279,7 @@ export const layer = Layer.effect(
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
}) })
.pipe( .pipe(
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"), Stream.filter(LLMEvent.is.textDelta),
Stream.map((e) => e.text), Stream.map((e) => e.text),
Stream.mkString, Stream.mkString,
Effect.orDie, Effect.orDie,
@@ -300,186 +296,6 @@ export const layer = Layer.effect(
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) }))) .pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
}) })
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
agent: Agent.Info
model: Provider.Model
session: Session.Info
tools?: Record<string, boolean>
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const run = yield* runner()
const promptOps = yield* ops()
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
ask: (req) =>
permission
.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
.pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
execute(args, options) {
return run.promise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger(
"tool.execute.before",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* item.execute(args, ctx)
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
output,
)
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
return output
}),
)
},
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
const execute = item.execute
if (!execute) continue
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = []
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const contentItem of result.content) {
if (contentItem.type === "text") textParts.push(contentItem.text)
else if (contentItem.type === "image") {
attachments.push({
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const { resource } = contentItem
if (resource.text) textParts.push(resource.text)
if (resource.blob) {
attachments.push({
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
filename: resource.uri,
})
}
}
}
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
const metadata = {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
}
const output = {
title: "",
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
content: result.content,
}
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
tools[key] = item
}
return tools
})
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
task: MessageV2.SubtaskPart task: MessageV2.SubtaskPart
model: Provider.Model model: Provider.Model
@@ -1551,16 +1367,23 @@ export const layer = Layer.effect(
const outcome: "break" | "continue" = yield* Effect.gen(function* () { const outcome: "break" | "continue" = yield* Effect.gen(function* () {
const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const promptOps = yield* ops()
const tools = yield* resolveTools({ const tools = yield* SessionTools.resolve({
agent, agent,
session, session,
model, model,
tools: lastUser.tools,
processor: handle, processor: handle,
bypassAgentCheck, bypassAgentCheck,
messages: msgs, messages: msgs,
}) promptOps,
}).pipe(
Effect.provideService(Plugin.Service, plugin),
Effect.provideService(Permission.Service, permission),
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
)
if (lastUser.format?.type === "json_schema") { if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({ tools["StructuredOutput"] = createStructuredOutputTool({
+6 -7
View File
@@ -4,7 +4,8 @@ import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "@opencode-ai/core/flag/flag"
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
@@ -374,21 +375,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
return path.join(base, [input.time.created, input.slug].join("-") + ".md") return path.join(base, [input.time.created, input.slug].join("-") + ".md")
} }
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
const safe = (value: number) => { const safe = (value: number) => {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
return Math.max(0, value) return Math.max(0, value)
} }
const inputTokens = safe(input.usage.inputTokens ?? 0) const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe( const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe( const cacheWriteInputTokens = safe(
Number( Number(
input.usage.inputTokenDetails?.cacheWriteTokens ?? input.usage.cacheWriteInputTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key // google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
+208
View File
@@ -0,0 +1,208 @@
import { Agent } from "@/agent/agent"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { ModelID } from "@/provider/schema"
import { Plugin } from "@/plugin"
import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { Effect } from "effect"
import { MessageV2 } from "./message-v2"
import * as Session from "./session"
import { SessionProcessor } from "./processor"
import { PartID } from "./schema"
import * as Log from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
const log = Log.create({ service: "session.tools" })
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
model: Provider.Model
session: Session.Info
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
promptOps: TaskPromptOps
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const run = yield* EffectBridge.make()
const plugin = yield* Plugin.Service
const permission = yield* Permission.Service
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
ask: (req) =>
permission
.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
.pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
execute(args, options) {
return run.promise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger(
"tool.execute.before",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* item.execute(args, ctx)
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
output,
)
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
return output
}),
)
},
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
const execute = item.execute
if (!execute) continue
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = []
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const contentItem of result.content) {
if (contentItem.type === "text") textParts.push(contentItem.text)
else if (contentItem.type === "image") {
attachments.push({
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const { resource } = contentItem
if (resource.text) textParts.push(resource.text)
if (resource.blob) {
attachments.push({
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
filename: resource.uri,
})
}
}
}
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
const metadata = {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
}
const output = {
title: "",
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
content: result.content,
}
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
tools[key] = item
}
return tools
})
export * as SessionTools from "./tools"
+5 -1
View File
@@ -40,6 +40,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
@@ -158,9 +159,12 @@ export const layer: Layer.Layer<
description: def.description, description: def.description,
execute: (args, toolCtx) => execute: (args, toolCtx) =>
Effect.gen(function* () { Effect.gen(function* () {
// Bridge the host's Effect-based `ask` into a Promise-returning
// function for the plugin to make sure context persists
const bridge = yield* EffectBridge.make()
const pluginCtx: PluginToolContext = { const pluginCtx: PluginToolContext = {
...toolCtx, ...toolCtx,
ask: (req) => toolCtx.ask(req), ask: (req) => bridge.promise(toolCtx.ask(req)),
directory: ctx.directory, directory: ctx.directory,
worktree: ctx.worktree, worktree: ctx.worktree,
} }
@@ -0,0 +1,70 @@
// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC
// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md.
// This is the only test tier that exercises the full pipe of bun startup →
// server boot → ACP agent init → stdio framing → graceful shutdown.
import { describe, expect } from "bun:test"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode acp (subprocess)", () => {
// Smoke test: send the `initialize` request from src/acp/README.md and
// assert the response advertises the same protocol version and a non-empty
// capabilities block. If this fails, every other ACP test will too — start
// debugging here.
cliIt.live(
"responds to initialize with protocolVersion 1 and capabilities",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp()
yield* acp.send({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: 1 },
})
// Tight deadline — the response should arrive within a few seconds
// once startup completes. A hang means the agent never finished init,
// which is a real regression and not a tuning issue.
const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as {
jsonrpc: string
id: number
result?: { protocolVersion: number; agentCapabilities: Record<string, unknown> }
error?: unknown
}
expect(response.jsonrpc).toBe("2.0")
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result?.protocolVersion).toBe(1)
expect(response.result?.agentCapabilities).toBeDefined()
}),
60_000,
)
// Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin"
// — if a future refactor breaks the stdin-end branch in the handler, the
// process would only exit on SIGTERM fallback (2s in the harness). This
// test passing within the inner-scope assertion proves the EOF path works.
cliIt.live(
"exits cleanly when stdin is closed (scope close)",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp()
// Capture the Promise — scope-close fires the finalizer which
// ends stdin, and ACP should exit gracefully.
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
// Bun returns a number for normal exit. Anything goes for SIGTERM,
// but we still require resolution within the test timeout.
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
})
@@ -0,0 +1,84 @@
// Subprocess integration tests for `opencode run` (non-interactive mode).
// These exercise the real CLI binary against a TestLLMServer running in the
// same process. See `test/lib/cli-process.ts` for the harness — each test uses
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode run (non-interactive subprocess)", () => {
// Happy path: prompt completes, output reaches stdout, process exits 0.
// If this fails, all the others likely will too — debug here first.
cliIt.live(
"exits 0 and writes the response to stdout on a successful prompt",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("hello from the test llm")
const result = yield* opencode.run("say hi")
opencode.expectExit(result, 0)
expect(result.stdout).toContain("hello from the test llm")
}),
60_000,
)
// Regression for #27371: an unknown model used to hang the process forever
// waiting on a session.status === idle event that never arrived. The fix
// makes the SDK call surface an error promptly so the process exits nonzero.
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
// would expire the timeout and produce a different (signal-killed) failure.
cliIt.live(
"exits nonzero promptly when the model is unknown (regression for #27371)",
({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("say hi", {
model: "test/nonexistent-model",
timeoutMs: 15_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(15_000)
}),
30_000,
)
// Locks in the current behavior: when the LLM stream errors mid-response
// (the prompt was accepted, then the upstream provider failed), opencode
// emits a session.error event and the process exits 0 today.
//
// This is debatable — a future cleanup might flip it to exit 1. If you're
// changing this expectation, do it deliberately and say so in the PR.
cliIt.live(
"mid-stream LLM error still exits 0 today (contract lock-in)",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.fail("upstream provider exploded mid-stream")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
}),
60_000,
)
// --format json puts one JSON object per line on stdout for each emitted
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
// shape so a future event-emit change has to update this expectation.
cliIt.live(
"--format json emits parseable line-delimited JSON to stdout",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("structured output")
const result = yield* opencode.run("say hi", { format: "json" })
opencode.expectExit(result, 0)
const events = opencode.parseJsonEvents(result.stdout)
expect(events.length).toBeGreaterThan(0)
for (const evt of events) {
expect(typeof evt.type).toBe("string")
expect(typeof evt.sessionID).toBe("string")
}
// At least one `text` event should appear with the LLM's response.
const text = events.find((e) => e.type === "text")
expect(text).toBeDefined()
}),
60_000,
)
})
@@ -0,0 +1,61 @@
// Subprocess integration tests for `opencode serve`. Spawns the real CLI in
// headless mode and exercises it over HTTP — this is the only test tier that
// catches bugs spanning argv → server boot → routing → instance loading.
//
// `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
// and kills the process when the test scope closes. The OS-assigned port is
// parsed off the "listening on http://..." line.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { cliIt } from "../../lib/cli-process"
describe("opencode serve (subprocess)", () => {
// Smoke test: server starts, binds a port, and /global/health responds.
// If this fails, all other serve tests likely will too — debug here first.
cliIt.live(
"starts, binds a port, and serves /global/health",
({ opencode }) =>
Effect.gen(function* () {
const server = yield* opencode.serve()
expect(server.port).toBeGreaterThan(0)
expect(server.url).toMatch(/^http:\/\//)
const client = yield* HttpClient.HttpClient
const res = yield* client.get(`${server.url}/global/health`)
expect(res.status).toBe(200)
// GlobalHealth schema is { success: true, ... } | { success: false, error }.
// We don't lock in further shape here — any 200 with parseable JSON is
// enough proof the routing + auth-bypass + instance loading is alive.
const body = yield* res.json
expect(body).toBeDefined()
}),
60_000,
)
// The scope-close finalizer must actually terminate the child. Without this
// test a regression in the kill path (e.g. a future refactor that forgets
// to wire the finalizer) would leak processes on every test run.
cliIt.live(
"kills the subprocess on scope close",
({ opencode }) =>
Effect.gen(function* () {
// Inner scope so we can observe `.exited` resolving after it closes.
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const server = yield* opencode.serve()
// Capture the Promise, not the resolved value — scope closes after
// this gen returns, at which point the finalizer kills the child.
return server.exited
}),
)
// After scope close: finalizer fired, process must have exited.
const code = yield* Effect.promise(() => exitedPromise)
// Bun reports the exit code; SIGTERM-killed processes return non-null
// (typically 143 on POSIX). We just require resolution within a sane
// window — anything else means the kill didn't take.
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
})
@@ -0,0 +1,54 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
test("legacy page key aliases compile as page keys", async () => {
const sequences: Record<string, string[][]> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig({
keybinds: {
messages_page_up: "pgup",
messages_page_down: "pgdown",
},
})
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offLayer = keymap.registerLayer({
bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]),
})
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: ["session.page.up", "session.page.down"],
})
sequences.up =
bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
sequences.down =
bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
onCleanup(() => {
offLayer()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(sequences).toEqual({
up: [["pageup"]],
down: [["pagedown"]],
})
} finally {
app.renderer.destroy()
}
})
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalEventSystem).toBe(true) expect(flags.experimentalEventSystem).toBe(true)
expect(flags.experimentalWorkspaces).toBe(true) expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true) expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(true)
expect(flags.client).toBe("desktop") expect(flags.client).toBe("desktop")
}), }),
) )
@@ -80,6 +81,16 @@ describe("RuntimeFlags", () => {
}), }),
) )
it.effect("enables native LLM via dedicated or umbrella flag", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalNativeLlm).toBe(true)
expect(umbrella.experimentalNativeLlm).toBe(true)
}),
)
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const flags = yield* readFlags.pipe( const flags = yield* readFlags.pipe(
@@ -0,0 +1,49 @@
{
"version": 1,
"metadata": {
"name": "session/native-anthropic-tool-loop",
"recordedAt": "2026-05-19T01:40:12.788Z",
"provider": "anthropic",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"tags": ["opencode", "native", "tool-loop"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"input_schema\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":32000,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KSRzhxWxF38x5yYVYvktbc\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":622,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":54,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01A8pEqifk2HVQfq1ZDNP6iY\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"P\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"aris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":622,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":54} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01A8pEqifk2HVQfq1ZDNP6iY\",\"name\":\"get_weather\",\"input\":{\"city\":{}}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01A8pEqifk2HVQfq1ZDNP6iY\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"input_schema\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":32000,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01UyghbuSVecMVozDny14vCD\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":697,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":697,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":7} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
+425
View File
@@ -0,0 +1,425 @@
// Subprocess test harness for the opencode CLI. Spawns the real binary against
// a TestLLMServer running in-process at a random port, with full env isolation.
//
// This is the missing test tier: in-process tests can't catch bugs that span
// argv parsing → server boot → SDK call → event consumption → exit code (like
// the original /event race or #27371's invalid-model hang).
//
// Configuration flows through opencode's built-in test affordances:
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
// - OPENCODE_PURE : skip external plugin discovery + install
// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
//
// Today only `opencode.run` is fully wired. The shape supports adding more
// builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
// without changing the fixture. Long-lived commands like `serve` will need a
// different return shape — see the TODO at the bottom of OpencodeCli.
import type { TestOptions } from "bun:test"
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import path from "node:path"
import fs from "node:fs/promises"
import os from "node:os"
import { Process } from "@/util/process"
import { TestLLMServer } from "./llm-server"
import { testProviderConfig } from "./test-provider"
import { it } from "./effect"
const opencodeRoot = path.resolve(import.meta.dir, "../../")
const cliEntry = path.join(opencodeRoot, "src/index.ts")
export const testModelID = "test/test-model"
function isolatedEnv(home: string, configJson: string): Record<string, string> {
return {
OPENCODE_TEST_HOME: home,
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local/share"),
XDG_STATE_HOME: path.join(home, ".local/state"),
XDG_CACHE_HOME: path.join(home, ".cache"),
OPENCODE_CONFIG_CONTENT: configJson,
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_AUTH_CONTENT: "{}",
}
}
export type RunResult = {
readonly exitCode: number
readonly stdout: string
readonly stderr: string
readonly durationMs: number
}
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
// Typed equivalent of constructing argv for `opencode run`. New flags should
// land here so tests stay grep-able and refactor-safe.
export type RunOpts = SpawnOpts & {
readonly model?: string
readonly agent?: string
readonly format?: "default" | "json"
readonly command?: string
readonly printLogs?: boolean
readonly extraArgs?: string[]
}
// `opencode serve` is a long-lived process — it never exits on its own.
// `serve(opts)` therefore returns a handle inside the caller's Scope: the
// subprocess is killed when the scope closes (test end), and the URL the
// server actually bound to (port 0 means OS-assigned) is parsed off stdout.
export type ServeOpts = SpawnOpts & {
readonly port?: number
readonly hostname?: string
readonly extraArgs?: string[]
// How long to wait for the "listening on http://..." line before failing.
// Default 15s — startup is dominated by bun's transpile + plugin init, not
// the actual listen() call.
readonly readyTimeoutMs?: number
}
export type ServeHandle = {
// Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
// as the base for HTTP requests in tests — never assume the port.
readonly url: string
readonly hostname: string
readonly port: number
// Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
// to invoke it directly — useful for tests that assert exit behavior.
readonly kill: () => void
// Resolves with the exit code once the process exits. Bun returns a number.
readonly exited: Promise<number>
}
// `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is
// long-lived and exits cleanly when stdin is closed. The handle exposes the
// duplex stream as send/receive rather than raw pipes so tests don't have to
// reimplement framing on every call site.
export type AcpOpts = SpawnOpts & {
readonly cwd?: string
readonly extraArgs?: string[]
}
export type AcpHandle = {
// Writes a single JSON-RPC message to the child's stdin as one ndjson line.
readonly send: (msg: object) => Effect.Effect<void>
// Resolves with the next parsed JSON-RPC line from the child's stdout.
// Lines are buffered in a queue so multiple receives in a row won't drop
// anything. Pair with `Effect.timeout` if a test wants a deadline.
readonly receive: Effect.Effect<unknown>
// Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also
// calls this, so tests only need it when asserting exit behavior.
readonly close: () => void
readonly exited: Promise<number>
}
export type OpencodeCli = {
// High-level: run a single prompt against the test model. Short-lived.
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
// returned handle is killed when the caller's Scope closes. Fails if the
// listening line doesn't appear within `readyTimeoutMs`.
readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
// Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived:
// the subprocess exits on stdin close, which the scope finalizer triggers.
readonly acp: (opts?: AcpOpts) => Effect.Effect<AcpHandle, Error, Scope.Scope>
// Escape hatch: any CLI invocation with full control over argv. Used to test
// commands that don't yet have a typed builder.
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
// failures are debuggable without re-running locally.
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
// Parse `--format json` stdout into one event object per non-empty line.
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
// event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
// tests fail loudly rather than silently skipping data.
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
}
export type CliFixture = {
readonly llm: TestLLMServer["Service"]
readonly home: string
readonly opencode: OpencodeCli
}
// Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
// up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
// the caller doesn't need to wire it up — the fixture's lifetime is tied to
// the surrounding Scope.
export function withCliFixture<A, E>(
fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
): Effect.Effect<A, E | unknown, Scope.Scope> {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
const home = path.join(os.tmpdir(), "oc-cli-" + Math.random().toString(36).slice(2))
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
)
const configJson = JSON.stringify(testProviderConfig(llm.url))
const env = isolatedEnv(home, configJson)
const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
Effect.promise(async () => {
const start = Date.now()
// Process.run pipes stdout/stderr by default and returns them as Buffers.
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
cwd: home,
timeout: opts?.timeoutMs ?? 30_000,
env: { ...process.env, ...env, ...opts?.env },
nothrow: true,
})
return {
exitCode: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
durationMs: Date.now() - start,
}
})
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
const argv: string[] = ["run"]
if (opts?.printLogs) argv.push("--print-logs")
argv.push("--model", opts?.model ?? testModelID)
if (opts?.agent) argv.push("--agent", opts.agent)
if (opts?.format) argv.push("--format", opts.format)
if (opts?.command) argv.push("--command", opts.command)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
argv.push(message)
return spawn(argv, opts)
}
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
const argv = ["serve"]
// Default port 0 — let the OS pick a free port, parse the actual one
// off stdout. Hard-coded ports flake under parallel tests.
argv.push("--port", String(opts?.port ?? 0))
if (opts?.hostname) argv.push("--hostname", opts.hostname)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
// Acquire the subprocess; release sends SIGTERM and awaits exit on
// scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
// as a finalizer error during test teardown.
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
cwd: home,
env: { ...process.env, ...env, ...opts?.env },
stdout: "pipe",
stderr: "pipe",
}),
),
(p) =>
Effect.promise(() => {
p.kill()
return p.exited
}).pipe(Effect.ignore),
)
// Drain stderr in a scope-bound fork. Without this the OS pipe buffer
// eventually fills and the child blocks on its next log call. Kept as a
// tail buffer so timeout failures can include context.
const stderrChunks: string[] = []
yield* Effect.forkScoped(
Stream.fromReadableStream({
evaluate: () => proc.stderr,
onError: () => new Error("stderr stream error"),
}).pipe(
Stream.decodeText(),
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
Effect.ignore,
),
)
// Watch stdout line-by-line for the listening sentinel. Format
// (see src/cli/cmd/serve.ts):
// "opencode server listening on http://<host>:<port>"
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
yield* Effect.forkScoped(
Stream.fromReadableStream({
evaluate: () => proc.stdout,
onError: () => new Error("stdout stream error"),
}).pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => {
const m = line.match(readyRe)
return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
}),
Effect.ignore,
),
)
const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
const match = yield* Deferred.await(readyDeferred).pipe(
Effect.timeoutOrElse({
duration: Duration.millis(readyTimeoutMs),
orElse: () =>
Effect.fail(
new Error(
`opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
`stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
),
),
}),
)
return {
url: match.url,
hostname: match.hostname,
port: match.port,
kill: () => {
proc.kill()
},
exited: proc.exited as Promise<number>,
} satisfies ServeHandle
})
const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) {
const argv = ["acp"]
if (opts?.cwd) argv.push("--cwd", opts.cwd)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
// Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits
// on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly.
// Either way we await proc.exited so the test scope doesn't leak.
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
cwd: opts?.cwd ?? home,
env: { ...process.env, ...env, ...opts?.env },
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
),
(p) =>
// Graceful shutdown: close stdin (ACP exits on EOF), give it a
// window to exit, then SIGTERM. The Effect.timeoutOrElse expresses
// exactly that race without raw setTimeout or Promise.race.
Effect.gen(function* () {
yield* Effect.sync(() => p.stdin.end())
yield* Effect.promise(() => p.exited).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(2),
orElse: () =>
Effect.sync(() => {
p.kill()
}),
}),
)
yield* Effect.promise(() => p.exited)
}).pipe(Effect.ignore),
)
const stderrChunks: string[] = []
yield* Effect.forkScoped(
Stream.fromReadableStream({
evaluate: () => proc.stderr,
onError: () => new Error("stderr stream error"),
}).pipe(
Stream.decodeText(),
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
Effect.ignore,
),
)
// Each ndjson line becomes one queue entry. JSON.parse failures are
// surfaced as the raw string so a malformed protocol message doesn't
// silently wedge the test in `receive`.
const responses = yield* Queue.unbounded<unknown>()
yield* Effect.forkScoped(
Stream.fromReadableStream({
evaluate: () => proc.stdout,
onError: () => new Error("stdout stream error"),
}).pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => {
if (line.length === 0) return Effect.void
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
parsed = { _rawLine: line }
}
return Queue.offer(responses, parsed)
}),
Effect.ignore,
),
)
return {
send: (msg: object) =>
Effect.sync(() => {
proc.stdin.write(JSON.stringify(msg) + "\n")
}),
receive: Queue.take(responses),
close: () => {
try {
proc.stdin.end()
} catch {
// already closed
}
},
exited: proc.exited as Promise<number>,
} satisfies AcpHandle
})
const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
return yield* fn({ llm, home, opencode })
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
}).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer)))
}
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
}
// Convenience for the common assertion pattern. Dumps stderr/stdout when
// the exit code doesn't match — saves debugging time on CI failures.
function expectExit(result: RunResult, expected: number, label = "opencode") {
if (result.exitCode === expected) return
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
// eslint-disable-next-line no-console
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
// eslint-disable-next-line no-console
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
// eslint-disable-next-line no-console
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
}
// `cliIt.live(name, fixture => effect)` is the same as
// `it.live(name, () => withCliFixture(fixture))` — one fewer nesting level at
// every call site. Use this for any test that needs the opencode CLI fixture.
//
// Only `.live` is exposed because subprocess tests must run against the real
// clock — a TestClock-paused environment can't drive a child process. If you
// need `.only` or `.skip`, fall back to `it.live` + `withCliFixture` directly.
// Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
// resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
// `withCliFixture`'s outer scope is the natural lifetime.
export const cliIt = {
live: <A, E>(
name: string,
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
opts?: number | TestOptions,
) => it.live(name, () => withCliFixture(body), opts),
}
@@ -0,0 +1,37 @@
// Shared provider config for tests that need opencode to talk to a fake LLM
// over a real HTTP endpoint. Registers a single provider `test` with a single
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
// caller supplies (typically a TestLLMServer instance).
//
// Used by:
// - test/lib/run-process.ts (subprocess CLI tests)
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
export function testProviderConfig(llmUrl: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100_000, output: 10_000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: { apiKey: "test-key", baseURL: llmUrl },
},
},
}
}
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type Method = "get" | "post" | "put" | "delete" | "patch"
type OpenApiSchema = { readonly $ref?: string }
type OpenApiResponse = {
readonly description?: string
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly responses?: Record<string, OpenApiResponse>
readonly security?: unknown
}
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
const methods = ["get", "post", "put", "delete", "patch"] as const
const allowedV2BuiltInEndpointErrors = [
"GET /api/session 400 effect_HttpApiError_BadRequest",
"GET /api/session/{sessionID}/message 400 effect_HttpApiError_BadRequest",
]
function v2Operations(spec: OpenApiSpec) {
return Object.entries(spec.paths).flatMap(([path, item]) =>
path.startsWith("/api/")
? methods.flatMap((method) => {
const operation = item[method]
return operation ? [{ method, path, operation }] : []
})
: [],
)
}
function responseRef(response: OpenApiResponse | undefined) {
return response?.content?.["application/json"]?.schema?.$ref
}
function componentName(ref: string) {
return ref.replace("#/components/schemas/", "")
}
function isBuiltInEndpointError(name: string) {
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
}
describe("PublicApi OpenAPI v2 errors", () => {
test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of v2Operations(spec)) {
expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
}
})
test("does not rewrite /api endpoint errors to legacy error components", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const refs = v2Operations(spec)
.flatMap((route) =>
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
const ref = responseRef(response)
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
}),
)
.filter((entry) => entry.includes("BadRequestError") || entry.includes("NotFoundError"))
expect(refs).toEqual(["GET /api/provider/{providerID} 404 NotFoundError"])
})
test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const builtInEndpointErrors = v2Operations(spec)
.flatMap((route) =>
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
if (status === "401") return []
const ref = responseRef(response)
if (!ref) return []
const name = componentName(ref)
return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
}),
)
.sort()
expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
})
})
@@ -23,6 +23,7 @@ import path from "path"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect" import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect( const it = testEffect(
@@ -99,39 +100,6 @@ function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
} }
function providerConfig(url: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100000, output: 10000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: {
apiKey: "test-key",
baseURL: url,
},
},
},
}
}
function call<T>(request: () => Promise<T>) { function call<T>(request: () => Promise<T>) {
return Effect.promise(request) return Effect.promise(request)
} }
@@ -283,7 +251,7 @@ function withStandardProject<A, E>(
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) { function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
return Effect.gen(function* () { return Effect.gen(function* () {
const llm = yield* TestLLMServer const llm = yield* TestLLMServer
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
}).pipe(Effect.provide(TestLLMServer.layer)) }).pipe(Effect.provide(TestLLMServer.layer))
} }
@@ -297,7 +265,7 @@ function withFakeLlmProject<A, E>(
return yield* withProject( return yield* withProject(
serverPath, serverPath,
{ {
config: providerConfig(llm.url), config: testProviderConfig(llm.url),
setup: options.setup, setup: options.setup,
}, },
(input) => run({ ...input, llm }), (input) => run({ ...input, llm }),
+89 -255
View File
@@ -30,6 +30,7 @@ import { TestConfig } from "../fixture/config"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { LLMEvent, Usage } from "@opencode-ai/llm"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -47,6 +48,10 @@ const ref = {
modelID: ModelID.make("test-model"), modelID: ModelID.make("test-model"),
} }
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
afterEach(() => { afterEach(() => {
mock.restore() mock.restore()
}) })
@@ -296,11 +301,11 @@ function readCompactionPart(sessionID: SessionID) {
function llm() { function llm() {
const queue: Array< const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>) Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
> = [] > = []
return { return {
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) { push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
queue.push(stream) queue.push(stream)
}, },
layer: Layer.succeed( layer: Layer.succeed(
@@ -319,54 +324,22 @@ function llm() {
function reply( function reply(
text: string, text: string,
capture?: (input: LLM.StreamInput) => void, capture?: (input: LLM.StreamInput) => void,
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> { ): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
return (input) => { return (input) => {
capture?.(input) capture?.(input)
return Stream.make( return Stream.make(
{ type: "start" } satisfies LLM.Event, LLMEvent.textStart({ id: "txt-0" }),
{ type: "text-start", id: "txt-0" } satisfies LLM.Event, LLMEvent.textDelta({ id: "txt-0", text }),
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event, LLMEvent.textEnd({ id: "txt-0" }),
{ type: "text-end", id: "txt-0" } satisfies LLM.Event, LLMEvent.stepFinish({
{ index: 0,
type: "finish-step", reason: "stop",
finishReason: "stop", usage: basicUsage(),
rawFinishReason: "stop", }),
response: { id: "res", modelId: "test-model", timestamp: new Date() }, LLMEvent.finish({
providerMetadata: undefined, reason: "stop",
usage: { usage: basicUsage(),
inputTokens: 1, }),
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
) )
} }
} }
@@ -1204,7 +1177,7 @@ describe("session.compaction.process", () => {
Stream.fromAsyncIterable( Stream.fromAsyncIterable(
{ {
async *[Symbol.asyncIterator]() { async *[Symbol.asyncIterator]() {
yield { type: "start" } as LLM.Event yield LLMEvent.stepStart({ index: 0 })
throw new APICallError({ throw new APICallError({
message: "boom", message: "boom",
url: "https://example.com/v1/chat/completions", url: "https://example.com/v1/chat/completions",
@@ -1290,55 +1263,62 @@ describe("session.compaction.process", () => {
{ git: true }, { git: true },
) )
itCompaction.instance(
"silently drops reasoning-delta arriving without prior reasoning-start",
() => {
// Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
// reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
// change to processor.ts reasoning-delta handling triggers this test.
const stub = llm()
stub.push(
Stream.make(
LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
LLMEvent.textStart({ id: "txt-0" }),
LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
LLMEvent.textEnd({ id: "txt-0" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const msg = yield* createUserMessage(session.id, "hello")
const msgs = yield* ssn.messages({ sessionID: session.id })
yield* SessionCompaction.use.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: false,
})
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
(item) => item.info.role === "assistant" && item.info.summary,
)
expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
// Sanity: the text part still got through.
expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
)
itCompaction.instance( itCompaction.instance(
"does not allow tool calls while generating the summary", "does not allow tool calls while generating the summary",
() => { () => {
const stub = llm() const stub = llm()
stub.push( stub.push(
Stream.make( Stream.make(
{ type: "start" } satisfies LLM.Event, LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
{ type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, LLMEvent.stepFinish({
{ type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, index: 0,
{ reason: "tool-calls",
type: "finish-step", usage: basicUsage(),
finishReason: "tool-calls", }),
rawFinishReason: "tool_calls", LLMEvent.finish({
response: { id: "res", modelId: "test-model", timestamp: new Date() }, reason: "tool-calls",
providerMetadata: undefined, usage: basicUsage(),
usage: { }),
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "tool-calls",
rawFinishReason: "tool_calls",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
), ),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -1544,20 +1524,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.tokens.input).toBe(1000) expect(result.tokens.input).toBe(1000)
@@ -1571,20 +1538,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.tokens.input).toBe(800) expect(result.tokens.input).toBe(800)
@@ -1595,20 +1549,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: { metadata: {
anthropic: { anthropic: {
cacheCreationInputTokens: 300, cacheCreationInputTokens: 300,
@@ -1624,20 +1565,7 @@ describe("SessionNs.getUsage", () => {
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers // AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: { metadata: {
anthropic: {}, anthropic: {},
}, },
@@ -1651,20 +1579,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100,
},
},
}) })
expect(result.tokens.input).toBe(1000) expect(result.tokens.input).toBe(1000)
@@ -1685,20 +1600,7 @@ describe("SessionNs.getUsage", () => {
}) })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000,
},
},
}) })
expect(result.tokens.output).toBe(750_000) expect(result.tokens.output).toBe(750_000)
@@ -1710,20 +1612,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.tokens.input).toBe(0) expect(result.tokens.input).toBe(0)
@@ -1746,20 +1635,7 @@ describe("SessionNs.getUsage", () => {
}) })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.cost).toBe(3 + 1.5) expect(result.cost).toBe(3 + 1.5)
@@ -1796,20 +1672,12 @@ describe("SessionNs.getUsage", () => {
}) })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({
inputTokens: 650_000, inputTokens: 650_000,
outputTokens: 100_000, outputTokens: 100_000,
totalTokens: 750_000, totalTokens: 750_000,
inputTokenDetails: { cacheReadInputTokens: 100_000,
noCacheTokens: undefined, }),
cacheReadTokens: 100_000,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.tokens.input).toBe(550_000) expect(result.tokens.input).toBe(550_000)
@@ -1841,20 +1709,7 @@ describe("SessionNs.getUsage", () => {
}) })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
inputTokens: 300_000,
outputTokens: 100_000,
totalTokens: 400_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
}) })
expect(result.cost).toBe(0.9 + 0.4) expect(result.cost).toBe(0.9 + 0.4)
@@ -1865,24 +1720,16 @@ describe("SessionNs.getUsage", () => {
(npm) => { (npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm }) const model = createModel({ context: 100_000, output: 32_000, npm })
// AI SDK v6: inputTokens includes cached tokens for all providers // AI SDK v6: inputTokens includes cached tokens for all providers
const usage = { const item = usage({
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
inputTokenDetails: { cacheReadInputTokens: 200,
noCacheTokens: 800, })
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}
if (npm === "@ai-sdk/amazon-bedrock") { if (npm === "@ai-sdk/amazon-bedrock") {
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage, usage: item,
metadata: { metadata: {
bedrock: { bedrock: {
usage: { usage: {
@@ -1903,7 +1750,7 @@ describe("SessionNs.getUsage", () => {
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage, usage: item,
metadata: { metadata: {
anthropic: { anthropic: {
cacheCreationInputTokens: 300, cacheCreationInputTokens: 300,
@@ -1924,20 +1771,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" }) const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = SessionNs.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: { metadata: {
vertex: { vertex: {
cacheCreationInputTokens: 300, cacheCreationInputTokens: 300,
@@ -0,0 +1,277 @@
import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { tool, type ModelMessage, type JSONValue } from "ai"
import { Effect, Layer, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "node:path"
import z from "zod"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { LLMEvent, LLMResponse } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, SessionID } from "../../src/session/schema"
import type { ModelsDev } from "@opencode-ai/core/models-dev"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
const zenURL = (connection: string) => `https://console.opencode.ai/proxy/connections/${connection}/v1`
type ProviderSpec = {
readonly providerID: ProviderID
readonly modelID: string
readonly cassette: string
readonly protocol: string
readonly tags: ReadonlyArray<string>
readonly canRecord: boolean
readonly config: (model: ModelsDev.Provider["models"][string]) => Partial<Config.Info>
}
const cloneModel = (model: ModelsDev.Provider["models"][string]) =>
structuredClone(model) as NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
const PROVIDERS = {
openai: {
providerID: ProviderID.openai,
modelID: "gpt-4.1-mini",
cassette: "session/native-openai-tool-loop",
protocol: "openai-responses",
tags: ["opencode", "native", "tool-loop"],
canRecord: Boolean(process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY),
config: (model) => ({
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: { [model.id]: cloneModel(model) },
options: {
apiKey: process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? "fixture-openai-key",
baseURL: "https://api.openai.com/v1",
},
},
},
}),
},
opencode: {
providerID: ProviderID.opencode,
modelID: "gpt-5.2-codex",
cassette: "session/native-zen-tool-loop",
protocol: "openai-responses",
tags: ["opencode", "zen", "native", "tool-loop"],
canRecord: Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID),
config: (model) => ({
enabled_providers: ["opencode"],
provider: {
opencode: {
name: "OpenCode Zen",
env: ["OPENCODE_CONSOLE_TOKEN"],
npm: "@ai-sdk/openai-compatible",
// The connection slug is account-specific; the cassette redactor
// normalizes it to {connection} for replay. Set during recording.
api: zenURL(process.env.OPENCODE_RECORD_ZEN_CONNECTION ?? "fixture"),
models: { [model.id]: cloneModel(model) },
options: {
apiKey: process.env.OPENCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token",
headers: { "x-org-id": process.env.OPENCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" },
},
},
},
}),
},
anthropic: {
providerID: ProviderID.anthropic,
modelID: "claude-haiku-4-5-20251001",
cassette: "session/native-anthropic-tool-loop",
protocol: "anthropic-messages",
tags: ["opencode", "native", "tool-loop"],
canRecord: Boolean(process.env.OPENCODE_RECORD_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY),
config: (model) => ({
enabled_providers: ["anthropic"],
provider: {
anthropic: {
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
npm: "@ai-sdk/anthropic",
api: "https://api.anthropic.com/v1",
models: { [model.id]: cloneModel(model) },
options: {
apiKey:
process.env.OPENCODE_RECORD_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? "fixture-anthropic-key",
baseURL: "https://api.anthropic.com/v1",
},
},
},
}),
},
} satisfies Record<string, ProviderSpec>
const shouldRecord = process.env.RECORD === "true"
const canRun = (spec: ProviderSpec) =>
shouldRecord ? spec.canRecord : HttpRecorder.hasCassetteSync(spec.cassette, { directory: FIXTURES_DIR })
async function loadFixture(providerID: string, modelID: string) {
const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(
path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
)
const provider = data[providerID]
if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
const model = provider.models[modelID]
if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
return model
}
function recordedNativeLLMLayer(spec: ProviderSpec) {
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedClient = LLMClient.layer.pipe(
Layer.provide(RequestExecutor.layer),
Layer.provide(
HttpRecorder.recordingLayer(spec.cassette, {
mode: shouldRecord ? "record" : "replay",
metadata: { provider: spec.providerID, protocol: spec.protocol, route: spec.protocol, tags: spec.tags },
redactor: Redactor.compose(
Redactor.defaults({
url: {
transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
},
}),
{
response: (snapshot) => ({ ...snapshot, body: snapshot.body.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") }),
},
),
}).pipe(Layer.provide(FetchHttpClient.layer)),
),
)
return Layer.mergeAll(
Provider.defaultLayer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
LLM.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(recordedClient),
Layer.provide(
HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(Layer.provide(NodeFileSystem.layer)),
),
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
),
)
}
const writeConfig = (directory: string, spec: ProviderSpec, model: ModelsDev.Provider["models"][string]) =>
Effect.promise(() =>
Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...spec.config(model) }),
),
)
const collect = (input: LLM.StreamInput) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
})
const WEATHER_RESULT = { temperature: 22, condition: "sunny" } as const
const WEATHER_SYSTEM =
"Use the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny."
const WEATHER_USER = "What is the weather in Paris?"
const weatherTool = tool({
description: "Get the current weather for a city.",
inputSchema: z.object({ city: z.string() }),
execute: async () => WEATHER_RESULT,
})
const toolRoundtrip = (
call: { readonly id: string; readonly name: string; readonly input: unknown },
result: JSONValue,
): ModelMessage[] => [
{ role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: call.id, toolName: call.name, output: { type: "json", value: result } },
],
},
]
const driveToolLoop = (spec: ProviderSpec) =>
Effect.gen(function* () {
const test = yield* TestInstance
const model = yield* Effect.promise(() => loadFixture(spec.providerID, spec.modelID))
yield* writeConfig(test.directory, spec, model)
const sessionID = SessionID.make(`session-recorded-${spec.providerID}-loop`)
const modelID = ModelID.make(model.id)
const agent = {
name: "test",
mode: "primary",
prompt: "Answer using tools when appropriate.",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0,
} satisfies Agent.Info
const provider = yield* Provider.Service
const resolved = yield* provider.getModel(spec.providerID, modelID)
const userMessage = { role: "user", content: WEATHER_USER } satisfies ModelMessage
const base = {
user: {
id: MessageID.make(`msg_user-recorded-${spec.providerID}-loop`),
sessionID,
role: "user",
time: { created: 0 },
agent: agent.name,
model: { providerID: spec.providerID, modelID },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: [WEATHER_SYSTEM],
tools: { get_weather: weatherTool },
}
const turn1 = yield* collect({ ...base, messages: [userMessage] })
const toolCall = turn1.find(LLMEvent.is.toolCall)
expect(toolCall).toBeDefined()
expect(turn1.find(LLMEvent.is.toolResult)).toBeDefined()
expect(toolCall!.name).toBe("get_weather")
expect(toolCall!.input).toMatchObject({ city: expect.stringMatching(/Paris/i) })
expect(turn1.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
const turn2 = yield* collect({
...base,
messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
})
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
expect(turn2.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(turn2.filter(LLMEvent.is.toolCall)).toHaveLength(0)
})
describe("session.llm native recorded", () => {
for (const [name, spec] of Object.entries(PROVIDERS)) {
const it = testEffect(recordedNativeLLMLayer(spec))
const instance = canRun(spec) ? it.instance : it.instance.skip
instance(`${name}: drives a tool loop to a final text answer`, () => driveToolLoop(spec))
}
})
@@ -0,0 +1,405 @@
import { describe, expect, test } from "bun:test"
import { ToolFailure } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { jsonSchema, tool, type ModelMessage } from "ai"
import { Effect } from "effect"
import { LLMNative } from "@/session/llm/native-request"
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
const baseModel: Provider.Model = {
id: ModelID.make("gpt-5-mini"),
providerID: ProviderID.make("openai"),
api: {
id: "gpt-5-mini",
url: "https://api.openai.com/v1",
npm: "@ai-sdk/openai",
},
name: "GPT-5 Mini",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128_000,
input: 128_000,
output: 32_000,
},
status: "active",
options: {},
headers: {
"x-model": "model-header",
},
release_date: "2026-01-01",
}
const providerInfo: Provider.Info = {
id: ProviderID.make("openai"),
name: "OpenAI",
source: "config",
env: ["OPENAI_API_KEY"],
options: { apiKey: "test-openai-key" },
models: {},
}
describe("session.llm-native.request", () => {
test("maps normalized stream inputs to a native LLM request", () => {
const messages: ModelMessage[] = [
{
role: "system",
content: "system from messages",
},
{
role: "user",
content: [
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
input: { command: "ls" },
providerOptions: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: { type: "text", value: "ok" },
providerOptions: { openai: { outputId: "output-1" } },
},
],
},
]
const request = LLMNative.request({
model: baseModel,
system: ["agent system"],
messages,
tools: {
bash: tool({
description: "Run a shell command",
inputSchema: jsonSchema({
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
}),
}),
},
toolChoice: "required",
temperature: 0.2,
topP: 0.9,
topK: 40,
maxOutputTokens: 1024,
providerOptions: { openai: { store: false } },
headers: { "x-request": "request-header" },
})
expect(request.model).toMatchObject({
id: "gpt-5-mini",
provider: "openai",
route: "openai-responses",
baseURL: "https://api.openai.com/v1",
headers: {
"x-model": "model-header",
"x-request": "request-header",
},
limits: {
context: 128_000,
output: 32_000,
},
})
expect(request.system).toEqual([
{ type: "text", text: "agent system" },
{ type: "text", text: "system from messages" },
])
expect(request.generation).toMatchObject({
temperature: 0.2,
topP: 0.9,
topK: 40,
maxTokens: 1024,
})
expect(request.providerOptions).toEqual({ openai: { store: false } })
expect(request.toolChoice).toMatchObject({ type: "required" })
expect(request.tools).toMatchObject([
{
name: "bash",
description: "Run a shell command",
inputSchema: {
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
},
},
])
expect(request.messages).toMatchObject([
{
role: "user",
content: [
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
id: "call-1",
name: "bash",
input: { command: "ls" },
providerMetadata: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call-1",
name: "bash",
result: { type: "text", value: "ok" },
providerMetadata: { openai: { outputId: "output-1" } },
},
],
},
])
})
test("selects native routes from existing provider packages", () => {
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }),
).toMatchObject({
route: "anthropic-messages",
baseURL: "https://api.anthropic.com/v1",
})
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
route: "gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta",
})
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
).toMatchObject({
route: "openai-compatible-chat",
baseURL: "https://api.openai.com/v1",
})
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
).toMatchObject({
route: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
})
})
test("fails fast for unsupported provider packages", () => {
expect(() =>
LLMNative.request({
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
messages: [],
}),
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
})
test("only enables native runtime for supported OpenAI API-key models", () => {
expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("opencode") },
provider: { ...providerInfo, id: ProviderID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("google") },
provider: { ...providerInfo, id: ProviderID.make("google") },
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" })
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: providerInfo,
auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
}),
).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" })
expect(
LLMNativeRuntime.status({
model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/google" } },
provider: providerInfo,
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI or Anthropic" })
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: { ...providerInfo, options: {} },
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "API key is not configured" })
})
test("enables native runtime for Anthropic API-key models", () => {
expect(
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderID.make("anthropic"),
api: { ...baseModel.api, npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" },
},
provider: {
...providerInfo,
id: ProviderID.make("anthropic"),
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
options: { apiKey: "test-anthropic-key" },
},
auth: undefined,
}),
).toMatchObject({ type: "supported", apiKey: "test-anthropic-key" })
})
test("prefers console provider api key over stored opencode auth", () => {
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("opencode") },
provider: {
...providerInfo,
id: ProviderID.make("opencode"),
options: { apiKey: "console-token" },
key: "zen-token",
},
auth: { type: "api", key: "zen-token" },
}),
).toMatchObject({
type: "supported",
apiKey: "console-token",
})
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: { ...providerInfo, options: {}, key: "provider-key" },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "provider-key",
})
})
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
const wrapped = LLMNativeRuntime.nativeTools(
{
explode: {
description: "always throws",
inputSchema: jsonSchema({ type: "object" }),
execute: async () => {
throw new Error("boom")
},
} as any,
},
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
)
const failure = await Effect.runPromise(
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
)
expect(failure).toBeInstanceOf(ToolFailure)
expect((failure as ToolFailure).message).toBe("boom")
})
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
// The native runtime owns execution, so encountering such a tool here means upstream
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
const wrapped = LLMNativeRuntime.nativeTools(
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
)
const failure = await Effect.runPromise(
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
)
expect(failure).toBeInstanceOf(ToolFailure)
expect((failure as ToolFailure).message).toContain("incomplete")
})
test("compiles through the native OpenAI Responses route", async () => {
const prepared = await Effect.runPromise(
LLMClient.prepare(
LLMNative.request({
model: baseModel,
messages: [{ role: "user", content: "hello" }],
providerOptions: { openai: { store: false } },
maxOutputTokens: 512,
headers: { "x-request": "request-header" },
}),
).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)),
)
expect(prepared).toMatchObject({
route: "openai-responses",
protocol: "openai-responses",
body: {
model: "gpt-5-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
max_output_tokens: 512,
store: false,
stream: true,
},
})
})
})
+842 -27
View File
@@ -1,15 +1,20 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { tool, type ModelMessage } from "ai" import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect" import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import z from "zod" import z from "zod"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
import type { InstanceContext } from "../../src/project/instance-context" import type { InstanceContext } from "../../src/project/instance-context"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Plugin } from "@/plugin"
import { ProviderID, ModelID } from "../../src/provider/schema" import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { tmpdir, withTestInstance } from "../fixture/fixture" import { tmpdir, withTestInstance } from "../fixture/fixture"
@@ -17,6 +22,33 @@ import type { Agent } from "../../src/agent/agent"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Permission } from "@/permission"
import { LLMAISDK } from "@/session/llm/ai-sdk"
import { Session as SessionNs } from "@/session/session"
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<Config.Info> => {
const { experimental: _experimental, ...configModel } = model
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
return {
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
},
options: {
apiKey: "test-openai-key",
baseURL,
},
},
},
}
}
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) { async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
const effect = Effect.gen(function* () { const effect = Effect.gen(function* () {
@@ -35,6 +67,26 @@ async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
}) })
} }
async function drainWith(layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput, ctx: InstanceContext) {
return Effect.runPromise(
LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)).pipe(
Effect.provide(layer),
Effect.provideService(InstanceRef, ctx),
),
)
}
function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, flags: Partial<RuntimeFlags.Info> = {}) {
return LLM.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
Layer.provide(RuntimeFlags.layer(flags)),
)
}
describe("session.llm.hasToolCalls", () => { describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => { test("returns false for empty messages array", () => {
expect(LLM.hasToolCalls([])).toBe(false) expect(LLM.hasToolCalls([])).toBe(false)
@@ -122,6 +174,338 @@ describe("session.llm.hasToolCalls", () => {
}) })
}) })
describe("session.llm.ai-sdk adapter", () => {
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
const adapt = (events: ReadonlyArray<AISDKAdapterEvent>) => {
const state = LLMAISDK.adapterState()
return Effect.runPromise(
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
)
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface
const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent
test("maps AI SDK stream chunks without losing session-visible fields", async () => {
const metadata = { openai: { itemID: "item-1" } }
const events = await adapt([
{ type: "start" },
{ type: "start-step", request: {}, warnings: [] },
{ type: "text-start", id: "text-1", providerMetadata: metadata },
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
{ type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata },
{ type: "tool-input-delta", id: "call-1", delta: '{"query":' },
{ type: "tool-input-delta", id: "call-1", delta: '"weather"}' },
{ type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "lookup",
input: { query: "weather" },
providerExecuted: true,
providerMetadata: { openai: { called: true } },
},
{
type: "tool-result",
toolCallId: "call-1",
toolName: "lookup",
input: { query: "weather" },
output: { title: "Lookup", output: "sunny", metadata: { ok: true } },
providerExecuted: true,
providerMetadata: { openai: { result: true } },
},
{
type: "finish-step",
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
finishReason: "other",
rawFinishReason: "other",
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 },
outputTokenDetails: { textTokens: 4, reasoningTokens: 1 },
},
providerMetadata: { openai: { step: true } },
},
{
type: "finish",
finishReason: "other",
rawFinishReason: "other",
totalUsage: {
inputTokens: 11,
outputTokens: 6,
totalTokens: 17,
cachedInputTokens: 4,
reasoningTokens: 2,
inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: 4, reasoningTokens: 2 },
},
},
])
expect(events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "text-1", providerMetadata: metadata },
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
{ type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata },
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' },
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' },
{ type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } },
{
type: "tool-call",
id: "call-1",
name: "lookup",
input: { query: "weather" },
providerExecuted: true,
providerMetadata: { openai: { called: true } },
},
{
type: "tool-result",
id: "call-1",
name: "lookup",
result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } },
providerExecuted: true,
providerMetadata: { openai: { result: true } },
},
{
type: "step-finish",
index: 0,
reason: "unknown",
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
reasoningTokens: 1,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
providerMetadata: { openai: { step: true } },
},
{
type: "finish",
reason: "unknown",
usage: {
inputTokens: 11,
outputTokens: 6,
totalTokens: 17,
reasoningTokens: 2,
cacheReadInputTokens: 4,
},
},
])
})
test("creates stable block ids when AI SDK omits them", async () => {
const events = await adapt([
uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }),
uncheckedAdapterEvent({ type: "text-end" }),
uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }),
uncheckedAdapterEvent({ type: "reasoning-end" }),
])
expect(events).toMatchObject([
{ type: "text-delta", id: "text-0", text: "implicit text" },
{ type: "text-end", id: "text-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" },
{ type: "reasoning-end", id: "reasoning-0" },
])
})
test("explicitly ignores non-session-visible AI SDK chunks", async () => {
expect(
await adapt([
uncheckedAdapterEvent({ type: "abort" }),
uncheckedAdapterEvent({ type: "source" }),
uncheckedAdapterEvent({ type: "file" }),
uncheckedAdapterEvent({ type: "raw" }),
uncheckedAdapterEvent({ type: "tool-output-denied" }),
uncheckedAdapterEvent({ type: "tool-approval-request" }),
]),
).toEqual([])
})
test("preserves tool-error cause", async () => {
const error = new Permission.RejectedError()
const events = await Effect.runPromise(
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
type: "tool-error",
toolCallId: "call_123",
toolName: "bash",
input: {},
error,
}),
)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool-error",
id: "call_123",
name: "bash",
message: error.message,
error,
})
})
test("emits undefined usage when every AI SDK usage field is missing", async () => {
// If every numeric field is undefined the translator should signal "no usage info"
// by emitting undefined, not by polluting the event with usage: {}. Downstream cost
// telemetry distinguishes "missing" from "zero," so emitting an empty object causes
// false positives ("usage was tracked, just empty") instead of correct nulls.
const events = await adapt([
{
type: "finish-step",
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
finishReason: "stop",
rawFinishReason: "stop",
providerMetadata: undefined,
usage: {
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
reasoningTokens: undefined,
cachedInputTokens: undefined,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
},
])
expect(events).toHaveLength(1)
const stepFinish = events[0]
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
expect(stepFinish.usage).toBeUndefined()
})
test("reuses adapter state cleanly across streams once finish has fired", async () => {
// adapterState() is meant to be per-stream, but the only thing finish currently clears
// is toolNames — step, text counters, and the current text/reasoning IDs all leak
// forward. A caller that reuses a state across two streams sees text-1/reasoning-1/
// step index 1 on the second stream's first events. The test pins the intended
// contract: after finish, the same state can be reused and starts fresh.
const state = LLMAISDK.adapterState()
const run = (events: ReadonlyArray<AISDKAdapterEvent>) =>
Effect.runPromise(
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
)
await run([
{ type: "start-step", request: {}, warnings: [] },
uncheckedAdapterEvent({ type: "text-delta", text: "first" }),
uncheckedAdapterEvent({ type: "text-end" }),
uncheckedAdapterEvent({ type: "reasoning-delta", text: "first reasoning" }),
uncheckedAdapterEvent({ type: "reasoning-end" }),
{
type: "finish-step",
response: { id: "r1", timestamp: new Date(0), modelId: "gpt-test" },
finishReason: "stop",
rawFinishReason: "stop",
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
},
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
},
])
const secondStream = await run([
{ type: "start-step", request: {}, warnings: [] },
uncheckedAdapterEvent({ type: "text-delta", text: "second" }),
uncheckedAdapterEvent({ type: "text-end" }),
uncheckedAdapterEvent({ type: "reasoning-delta", text: "second reasoning" }),
uncheckedAdapterEvent({ type: "reasoning-end" }),
])
expect(secondStream).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "text-delta", id: "text-0", text: "second" },
{ type: "text-end", id: "text-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" },
{ type: "reasoning-end", id: "reasoning-0" },
])
})
// Anthropic emits cache write counts in providerMetadata.anthropic.cacheCreationInputTokens
// rather than usage.inputTokenDetails.cacheWriteTokens. Session.getUsage falls back to the
// metadata path — but only if the adapter preserves providerMetadata on step-finish.
test("preserves providerMetadata on step-finish so Anthropic cache writes survive getUsage", async () => {
const events = await adapt([
{
type: "finish-step",
response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-3-5-sonnet" },
finishReason: "stop",
rawFinishReason: "stop",
// Anthropic's AI SDK shape: cacheWriteTokens is NOT in usage, it arrives via providerMetadata.
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: { noCacheTokens: 800, cacheReadTokens: 200, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: 500, reasoningTokens: undefined },
},
providerMetadata: { anthropic: { cacheCreationInputTokens: 300 } },
},
])
expect(events).toHaveLength(1)
const stepFinish = events[0]
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
expect(stepFinish.providerMetadata).toEqual({ anthropic: { cacheCreationInputTokens: 300 } })
expect(stepFinish.usage?.cacheWriteInputTokens).toBeUndefined()
expect(stepFinish.usage?.cacheReadInputTokens).toBe(200)
// End-to-end: with the metadata preserved, getUsage extracts cache.write from the fallback path.
const result = SessionNs.getUsage({
model: {
id: "claude-3-5-sonnet",
providerID: "anthropic",
name: "Claude",
limit: { context: 200_000, output: 8_000 },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
capabilities: {
toolcall: true,
attachment: false,
reasoning: false,
temperature: true,
input: { text: true, image: false, audio: false, video: false },
output: { text: true, image: false, audio: false, video: false },
},
api: { npm: "@ai-sdk/anthropic" },
options: {},
} as never,
usage: stepFinish.usage!,
metadata: stepFinish.providerMetadata,
})
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.cache.read).toBe(200)
})
})
type Capture = { type Capture = {
url: URL url: URL
headers: Headers headers: Headers
@@ -608,6 +992,18 @@ describe("session.llm.stream", () => {
service_tier: null, service_tier: null,
}, },
}, },
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-1",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{ {
type: "response.output_text.delta", type: "response.output_text.delta",
item_id: "item-1", item_id: "item-1",
@@ -630,32 +1026,7 @@ describe("session.llm.stream", () => {
] ]
const request = waitRequest("/responses", createEventResponse(responseChunks, true)) const request = waitRequest("/responses", createEventResponse(responseChunks, true))
await using tmp = await tmpdir({ await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: configModel(model),
},
options: {
apiKey: "test-openai-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await withTestInstance({ await withTestInstance({
directory: tmp.path, directory: tmp.path,
@@ -706,6 +1077,438 @@ describe("session.llm.stream", () => {
}) })
}) })
test("keeps supported OpenAI models on AI SDK path when native flag is off", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const request = waitRequest(
"/responses",
createEventResponse(
[
{
type: "response.created",
response: {
id: "resp-flag-off",
created_at: Math.floor(Date.now() / 1000),
model: model.id,
service_tier: null,
},
},
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-flag-off",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{
type: "response.output_text.delta",
item_id: "item-flag-off",
delta: "Flag off",
logprobs: null,
},
{
type: "response.completed",
response: {
incomplete_details: null,
usage: {
input_tokens: 1,
input_tokens_details: null,
output_tokens: 1,
output_tokens_details: null,
},
service_tier: null,
},
},
],
true,
),
)
const failingNativeClient = Layer.succeed(
LLMClient.Service,
LLMClient.Service.of({
prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")),
generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
}),
)
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-native-flag-off")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
await drainWith(
LLM.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(failingNativeClient),
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })),
),
{
user: {
id: MessageID.make("msg_user-native-flag-off"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
expect(capture.body.model).toBe(resolved.api.id)
},
})
})
test("streams OpenAI through native runtime when opted in", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.created",
response: {
id: "resp-native",
},
},
{
type: "response.output_item.added",
item: { type: "message", id: "item-native", status: "in_progress" },
},
{
type: "response.output_text.delta",
item_id: "item-native",
delta: "Hello native",
},
{
type: "response.completed",
response: {
incomplete_details: null,
usage: {
input_tokens: 1,
input_tokens_details: null,
output_tokens: 1,
output_tokens_details: null,
},
},
},
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-native")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0.2,
} satisfies Agent.Info
await drainWith(
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
{
user: {
id: MessageID.make("msg_user-native"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
expect(capture.body.model).toBe(model.id)
expect(capture.body.stream).toBe(true)
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
},
})
})
test("uses injected native request executor for tool calls", async () => {
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.output_item.added",
item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
},
{
type: "response.function_call_arguments.delta",
item_id: "item-injected-tool",
delta: '{"query":"weather"}',
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item-injected-tool",
call_id: "call-injected-tool",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.completed",
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
},
]
let captured: Record<string, unknown> | undefined
let executed: unknown
const executor = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({
execute: (request) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
}),
}),
)
await using tmp = await tmpdir({ config: openAIConfig(model, "https://injected-openai.test/v1") })
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-native-injected-tool")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
await drainWith(
llmLayerWithExecutor(executor, { experimentalNativeLlm: true }),
{
user: {
id: MessageID.make("msg_user-native-injected-tool"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: [],
messages: [{ role: "user", content: "Use lookup" }],
tools: {
lookup: tool({
description: "Lookup data",
inputSchema: z.object({ query: z.string() }),
execute: async (args, options) => {
executed = { args, toolCallId: options.toolCallId }
return { output: "looked up" }
},
}),
},
},
ctx,
)
expect(captured?.model).toBe(model.id)
expect(captured?.tools).toEqual([
{
type: "function",
name: "lookup",
description: "Lookup data",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
$schema: "http://json-schema.org/draft-07/schema#",
},
},
])
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
},
})
})
test("executes OpenAI tool calls through native runtime", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.output_item.added",
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
},
{
type: "response.function_call_arguments.delta",
item_id: "item-native-tool",
delta: '{"query":"weather"}',
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item-native-tool",
call_id: "call-native-tool",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.completed",
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
},
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
let executed: unknown
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-openai-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-native-tool")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
await drainWith(
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
{
user: {
id: MessageID.make("msg_user-native-tool"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: [],
messages: [{ role: "user", content: "Use lookup" }],
tools: {
lookup: tool({
description: "Lookup data",
inputSchema: z.object({ query: z.string() }),
execute: async (args, options) => {
executed = { args, toolCallId: options.toolCallId }
return { output: "looked up" }
},
}),
},
},
ctx,
)
const capture = await request
expect(capture.body.tools).toEqual([
{
type: "function",
name: "lookup",
description: "Lookup data",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
$schema: "http://json-schema.org/draft-07/schema#",
},
},
])
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
},
})
})
test("accepts user image attachments as data URLs for OpenAI models", async () => { test("accepts user image attachments as data URLs for OpenAI models", async () => {
const server = state.server const server = state.server
if (!server) { if (!server) {
@@ -724,6 +1527,18 @@ describe("session.llm.stream", () => {
service_tier: null, service_tier: null,
}, },
}, },
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-data-url",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{ {
type: "response.output_text.delta", type: "response.output_text.delta",
item_id: "item-data-url", item_id: "item-data-url",
@@ -316,6 +316,105 @@ describe("session.message-v2.toModelMessage", () => {
]) ])
}) })
test("includes source paths for user media attachments", async () => {
const messageID = "m-user-source"
const result = await MessageV2.toModelMessages(
[
{
info: userInfo(messageID),
parts: [
{
...basePart(messageID, "p1-source"),
type: "text",
text: "copy this image",
},
{
...basePart(messageID, "p2-source"),
type: "file",
mime: "image/png",
filename: "screenshot.png",
url: "data:image/png;base64,AAA",
source: {
type: "file",
path: "/repo/assets/screenshot.png",
text: {
value: "[Image 1]",
start: 16,
end: 25,
},
},
},
] as MessageV2.Part[],
},
],
model,
)
expect(result).toMatchObject([
{
role: "user",
content: [
{ type: "text", text: "copy this image" },
{
type: "text",
text: "Attached image: screenshot.png\nSource path: /repo/assets/screenshot.png\nPrompt reference: [Image 1]",
},
{
type: "file",
mediaType: "image/png",
filename: "screenshot.png",
},
],
},
])
})
test("does not expose clipboard placeholder as a source path", async () => {
const messageID = "m-user-clipboard"
expect(
await MessageV2.toModelMessages(
[
{
info: userInfo(messageID),
parts: [
{
...basePart(messageID, "p1-clipboard"),
type: "file",
mime: "image/png",
filename: "clipboard",
url: "data:image/png;base64,AAA",
source: {
type: "file",
path: "clipboard",
text: {
value: "[Image 1]",
start: 0,
end: 9,
},
},
},
] as MessageV2.Part[],
},
],
model,
),
).toStrictEqual([
{
role: "user",
content: [
{
type: "file",
mediaType: "image/png",
filename: "clipboard",
data: "data:image/png;base64,AAA",
},
],
},
])
})
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => { test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
const userID = "m-user" const userID = "m-user"
const assistantID = "m-assistant" const assistantID = "m-assistant"
@@ -1,7 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { expect } from "bun:test" import { expect } from "bun:test"
import { tool } from "ai"
import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path" import path from "path"
import z from "zod"
import type { Agent } from "../../src/agent/agent" import type { Agent } from "../../src/agent/agent"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
@@ -661,6 +663,71 @@ it.live("session.processor effect tests compact on structured context overflow",
), ),
) )
it.live("session.processor effect tests complete AI SDK tool calls when native flag is off", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
yield* llm.tool("lookup", { query: "weather" })
const chat = yield* session.create({})
const parent = yield* user(chat.id, "tool")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies MessageV2.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "tool" }],
tools: {
lookup: tool({
description: "Look up information",
inputSchema: z.object({ query: z.string() }),
execute: async (input) => ({
title: "Weather lookup",
output: `result:${input.query}`,
metadata: { source: "test" },
}),
}),
},
})
const parts = MessageV2.parts(msg.id)
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
expect(value).toBe("continue")
expect(yield* llm.calls).toBe(1)
expect(call?.callID).toBe("call_1")
expect(call?.tool).toBe("lookup")
expect(call?.state.status).toBe("completed")
if (call?.state.status !== "completed") return
expect(call.state.input).toEqual({ query: "weather" })
expect(call.state.output).toBe("result:weather")
expect(call.state.title).toBe("Weather lookup")
expect(call.state.metadata).toEqual({ source: "test" })
expect(call.state.time.start).toBeDefined()
expect(call.state.time.end).toBeDefined()
}),
{ git: true, config: (url) => providerCfg(url) },
),
)
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
provideTmpdirServer( provideTmpdirServer(
({ dir, llm }) => ({ dir, llm }) =>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -2
View File
@@ -1,5 +1,4 @@
import { z } from "zod" import { z } from "zod"
import { Effect } from "effect"
export type ToolContext = { export type ToolContext = {
sessionID: string sessionID: string
@@ -17,7 +16,7 @@ export type ToolContext = {
worktree: string worktree: string
abort: AbortSignal abort: AbortSignal
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
ask(input: AskInput): Effect.Effect<void> ask(input: AskInput): Promise<void>
} }
type AskInput = { type AskInput = {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+12 -6
View File
@@ -197,17 +197,23 @@ import type {
TuiSelectSessionResponses, TuiSelectSessionResponses,
TuiShowToastResponses, TuiShowToastResponses,
TuiSubmitPromptResponses, TuiSubmitPromptResponses,
V2ModelListErrors,
V2ModelListResponses, V2ModelListResponses,
V2ProviderGetErrors, V2ProviderGetErrors,
V2ProviderGetResponses, V2ProviderGetResponses,
V2ProviderListErrors,
V2ProviderListResponses, V2ProviderListResponses,
V2SessionCompactErrors,
V2SessionCompactResponses, V2SessionCompactResponses,
V2SessionContextErrors,
V2SessionContextResponses, V2SessionContextResponses,
V2SessionListErrors, V2SessionListErrors,
V2SessionListResponses, V2SessionListResponses,
V2SessionMessagesErrors, V2SessionMessagesErrors,
V2SessionMessagesResponses, V2SessionMessagesResponses,
V2SessionPromptErrors,
V2SessionPromptResponses, V2SessionPromptResponses,
V2SessionWaitErrors,
V2SessionWaitResponses, V2SessionWaitResponses,
VcsApplyErrors, VcsApplyErrors,
VcsApplyResponses, VcsApplyResponses,
@@ -4232,7 +4238,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionPromptResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionPromptResponses, V2SessionPromptErrors, ThrowOnError>({
url: "/api/session/{sessionID}/prompt", url: "/api/session/{sessionID}/prompt",
...options, ...options,
...params, ...params,
@@ -4269,7 +4275,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionCompactResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
url: "/api/session/{sessionID}/compact", url: "/api/session/{sessionID}/compact",
...options, ...options,
...params, ...params,
@@ -4301,7 +4307,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionWaitResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionWaitResponses, V2SessionWaitErrors, ThrowOnError>({
url: "/api/session/{sessionID}/wait", url: "/api/session/{sessionID}/wait",
...options, ...options,
...params, ...params,
@@ -4333,7 +4339,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<V2SessionContextResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<V2SessionContextResponses, V2SessionContextErrors, ThrowOnError>({
url: "/api/session/{sessionID}/context", url: "/api/session/{sessionID}/context",
...options, ...options,
...params, ...params,
@@ -4395,7 +4401,7 @@ export class Model extends HeyApiClient {
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<V2ModelListResponses, V2ModelListErrors, ThrowOnError>({
url: "/api/model", url: "/api/model",
...options, ...options,
...params, ...params,
@@ -4419,7 +4425,7 @@ export class Provider2 extends HeyApiClient {
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ProviderListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<V2ProviderListResponses, V2ProviderListErrors, ThrowOnError>({
url: "/api/provider", url: "/api/provider",
...options, ...options,
...params, ...params,
+62 -4
View File
@@ -104,6 +104,10 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EffectHttpApiErrorBadRequest = {
_tag: "BadRequest"
}
export type EventTuiPromptAppend = { export type EventTuiPromptAppend = {
id: string id: string
type: "tui.prompt.append" type: "tui.prompt.append"
@@ -6639,9 +6643,13 @@ export type V2SessionListData = {
export type V2SessionListErrors = { export type V2SessionListErrors = {
/** /**
* Bad request * BadRequest
*/ */
400: BadRequestError 400: EffectHttpApiErrorBadRequest
/**
* Unauthorized
*/
401: unknown
} }
export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors]
@@ -6670,6 +6678,13 @@ export type V2SessionPromptData = {
url: "/api/session/{sessionID}/prompt" url: "/api/session/{sessionID}/prompt"
} }
export type V2SessionPromptErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2SessionPromptResponses = { export type V2SessionPromptResponses = {
/** /**
* Session.Message * Session.Message
@@ -6691,6 +6706,13 @@ export type V2SessionCompactData = {
url: "/api/session/{sessionID}/compact" url: "/api/session/{sessionID}/compact"
} }
export type V2SessionCompactErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2SessionCompactResponses = { export type V2SessionCompactResponses = {
/** /**
* <No Content> * <No Content>
@@ -6712,6 +6734,13 @@ export type V2SessionWaitData = {
url: "/api/session/{sessionID}/wait" url: "/api/session/{sessionID}/wait"
} }
export type V2SessionWaitErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2SessionWaitResponses = { export type V2SessionWaitResponses = {
/** /**
* <No Content> * <No Content>
@@ -6733,6 +6762,13 @@ export type V2SessionContextData = {
url: "/api/session/{sessionID}/context" url: "/api/session/{sessionID}/context"
} }
export type V2SessionContextErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2SessionContextResponses = { export type V2SessionContextResponses = {
/** /**
* Success * Success
@@ -6762,9 +6798,13 @@ export type V2SessionMessagesData = {
export type V2SessionMessagesErrors = { export type V2SessionMessagesErrors = {
/** /**
* Bad request * BadRequest
*/ */
400: BadRequestError 400: EffectHttpApiErrorBadRequest
/**
* Unauthorized
*/
401: unknown
} }
export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors]
@@ -6790,6 +6830,13 @@ export type V2ModelListData = {
url: "/api/model" url: "/api/model"
} }
export type V2ModelListErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2ModelListResponses = { export type V2ModelListResponses = {
/** /**
* Success * Success
@@ -6811,6 +6858,13 @@ export type V2ProviderListData = {
url: "/api/provider" url: "/api/provider"
} }
export type V2ProviderListErrors = {
/**
* Unauthorized
*/
401: unknown
}
export type V2ProviderListResponses = { export type V2ProviderListResponses = {
/** /**
* Success * Success
@@ -6835,6 +6889,10 @@ export type V2ProviderGetData = {
} }
export type V2ProviderGetErrors = { export type V2ProviderGetErrors = {
/**
* Unauthorized
*/
401: unknown
/** /**
* NotFoundError * NotFoundError
*/ */
+51 -4
View File
@@ -7265,6 +7265,7 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "V2SessionsResponse", "description": "V2SessionsResponse",
@@ -7277,14 +7278,17 @@
} }
}, },
"400": { "400": {
"description": "Bad request", "description": "BadRequest",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BadRequestError" "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
@@ -7328,6 +7332,7 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "Session.Message", "description": "Session.Message",
@@ -7338,6 +7343,9 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Create a v2 session message and queue it for the agent loop.", "description": "Create a v2 session message and queue it for the agent loop.",
@@ -7400,9 +7408,13 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"204": { "204": {
"description": "<No Content>" "description": "<No Content>"
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Compact a v2 session conversation.", "description": "Compact a v2 session conversation.",
@@ -7446,9 +7458,13 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"204": { "204": {
"description": "<No Content>" "description": "<No Content>"
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Wait for a v2 session agent loop to become idle.", "description": "Wait for a v2 session agent loop to become idle.",
@@ -7492,6 +7508,7 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@@ -7505,6 +7522,9 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
@@ -7574,6 +7594,7 @@
"required": false "required": false
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "V2SessionMessagesResponse", "description": "V2SessionMessagesResponse",
@@ -7586,14 +7607,17 @@
} }
}, },
"400": { "400": {
"description": "Bad request", "description": "BadRequest",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BadRequestError" "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
@@ -7631,6 +7655,7 @@
"explode": true "explode": true
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@@ -7644,6 +7669,9 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Retrieve available v2 models ordered by release date.", "description": "Retrieve available v2 models ordered by release date.",
@@ -7681,6 +7709,7 @@
"explode": true "explode": true
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@@ -7694,6 +7723,9 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
}, },
"description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.", "description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
@@ -7739,6 +7771,7 @@
"explode": true "explode": true
} }
], ],
"security": [],
"responses": { "responses": {
"200": { "200": {
"description": "ProviderV2.Info", "description": "ProviderV2.Info",
@@ -7750,6 +7783,9 @@
} }
} }
}, },
"401": {
"description": "Unauthorized"
},
"404": { "404": {
"description": "NotFoundError", "description": "NotFoundError",
"content": { "content": {
@@ -9419,6 +9455,17 @@
} }
] ]
}, },
"effect_HttpApiError_BadRequest": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["BadRequest"]
}
},
"required": ["_tag"],
"additionalProperties": false
},
"Event.tui.prompt.append": { "Event.tui.prompt.append": {
"type": "object", "type": "object",
"properties": { "properties": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.4", "version": "1.15.5",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -1563,7 +1563,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
const streaming = createMemo( const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
) )
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim() const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim()
return ( return (
<Show when={text()}> <Show when={text()}>
+167
View File
@@ -0,0 +1,167 @@
export const dict: Record<string, string> = {
"ui.sessionReview.title": "Зміни сесії",
"ui.sessionReview.title.git": "Зміни Git",
"ui.sessionReview.title.branch": "Зміни гілки",
"ui.sessionReview.title.lastTurn": "Зміни останнього кроку",
"ui.sessionReview.diffStyle.unified": "Об'єднаний",
"ui.sessionReview.diffStyle.split": "Розділений",
"ui.sessionReview.expandAll": "Розгорнути все",
"ui.sessionReview.collapseAll": "Згорнути все",
"ui.sessionReview.change.added": "Додано",
"ui.sessionReview.change.removed": "Видалено",
"ui.sessionReview.change.modified": "Змінено",
"ui.sessionReview.image.loading": "Завантаження...",
"ui.sessionReview.image.placeholder": "Зображення",
"ui.sessionReview.largeDiff.title": "Завеликий diff для відображення",
"ui.sessionReview.largeDiff.meta": "Ліміт: {{limit}} змінених рядків. Поточно: {{current}} змінених рядків.",
"ui.sessionReview.largeDiff.renderAnyway": "Все одно відобразити",
"ui.sessionReview.openFile": "Відкрити файл",
"ui.sessionReview.selection.line": "рядок {{line}}",
"ui.sessionReview.selection.lines": "рядки {{start}}-{{end}}",
"ui.fileMedia.kind.image": "зображення",
"ui.fileMedia.kind.audio": "аудіо",
"ui.fileMedia.state.removed": "{{kind}} видалено",
"ui.fileMedia.state.loading": "Завантаження {{kind}}...",
"ui.fileMedia.state.error": "Не вдалося завантажити {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} недоступне",
"ui.fileMedia.binary.title": "Бінарний файл",
"ui.fileMedia.binary.description.path": "Неможливо відобразити {{path}}, оскільки це бінарний файл.",
"ui.fileMedia.binary.description.default": "Неможливо відобразити цей файл, оскільки він бінарний.",
"ui.lineComment.label.prefix": "Коментар до ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Коментування: ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.placeholder": "Додати коментар",
"ui.lineComment.submit": "Коментувати",
"ui.sessionTurn.steps.show": "Показати кроки",
"ui.sessionTurn.steps.hide": "Приховати кроки",
"ui.sessionTurn.summary.response": "Відповідь",
"ui.sessionTurn.diff.showMore": "Показати більше змін ({{count}})",
"ui.sessionTurn.diffs.changed": "Змінено",
"ui.sessionTurn.diffs.showAll": "Показати всі",
"ui.sessionTurn.diffs.showLess": "Показати менше",
"ui.sessionTurn.diffs.more": "+{{count}} інших файлів",
"ui.sessionTurn.retry.retrying": "повтор",
"ui.sessionTurn.retry.inSeconds": "за {{seconds}}с",
"ui.sessionTurn.retry.attempt": "спроба №{{attempt}}",
"ui.sessionTurn.retry.attemptLine": "{{line}} — спроба №{{attempt}}",
"ui.sessionTurn.retry.geminiHot": "gemini зараз перевантажений",
"ui.sessionTurn.error.freeUsageExceeded": "Перевищено ліміт безкоштовного використання",
"ui.sessionTurn.error.addCredits": "Додати кредити",
"ui.sessionTurn.status.delegating": "Делегування роботи",
"ui.sessionTurn.status.planning": "Планування наступних кроків",
"ui.sessionTurn.status.gatheringContext": "Дослідження",
"ui.sessionTurn.status.gatheredContext": "Досліджено",
"ui.sessionTurn.status.searchingCodebase": "Пошук у кодовій базі",
"ui.sessionTurn.status.searchingWeb": "Пошук в інтернеті",
"ui.sessionTurn.status.makingEdits": "Внесення змін",
"ui.sessionTurn.status.runningCommands": "Виконання команд",
"ui.sessionTurn.status.thinking": "Міркування",
"ui.sessionTurn.status.thinkingWithTopic": "Міркування — {{topic}}",
"ui.sessionTurn.status.gatheringThoughts": "Збирання думок",
"ui.sessionTurn.status.consideringNextSteps": "Розгляд наступних кроків",
"ui.messagePart.diagnostic.error": "Помилка",
"ui.messagePart.title.edit": "Редагувати",
"ui.messagePart.title.write": "Написати",
"ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь",
"ui.messagePart.review.title": "Перевірте свої відповіді",
"ui.messagePart.questions.dismissed": "Питання відхилено",
"ui.messagePart.compaction": "Сесію стиснуто",
"ui.messagePart.context.read.one": "{{count}} читання",
"ui.messagePart.context.read.other": "{{count}} читань",
"ui.messagePart.context.search.one": "{{count}} пошук",
"ui.messagePart.context.search.other": "{{count}} пошуків",
"ui.messagePart.context.list.one": "{{count}} список",
"ui.messagePart.context.list.other": "{{count}} списків",
"ui.list.loading": "Завантаження",
"ui.list.empty": "Немає результатів",
"ui.list.clearFilter": "Очистити фільтр",
"ui.list.emptyWithFilter.prefix": "Немає результатів для",
"ui.list.emptyWithFilter.suffix": "",
"ui.fileSearch.placeholder": "Знайти",
"ui.fileSearch.previousMatch": "Попередній збіг",
"ui.fileSearch.nextMatch": "Наступний збіг",
"ui.fileSearch.close": "Закрити пошук",
"ui.messageNav.newMessage": "Нове повідомлення",
"ui.textField.copyToClipboard": "Копіювати в буфер обміну",
"ui.textField.copyLink": "Копіювати посилання",
"ui.textField.copied": "Скопійовано",
"ui.imagePreview.alt": "Попередній перегляд зображення",
"ui.scrollView.ariaLabel": "контент для прокручування",
"ui.tool.read": "Читання",
"ui.tool.loaded": "Завантажено",
"ui.tool.list": "Список",
"ui.tool.glob": "Glob",
"ui.tool.grep": "Grep",
"ui.tool.task": "Завдання",
"ui.tool.webfetch": "Веб-отримання",
"ui.tool.websearch": "Веб-пошук",
"ui.tool.shell": "Оболонка",
"ui.tool.patch": "Патч",
"ui.tool.todos": "Завдання",
"ui.tool.todos.read": "Читати завдання",
"ui.tool.questions": "Питання",
"ui.tool.agent": "Агент {{type}}",
"ui.tool.agent.default": "Агент",
"ui.tool.skill": "Навичка",
"ui.basicTool.called": "Викликано `{{tool}}`",
"ui.toolErrorCard.failed": "Помилка",
"ui.toolErrorCard.copyError": "Копіювати помилку",
"ui.common.file.one": "файл",
"ui.common.file.other": "файлів",
"ui.common.question.one": "питання",
"ui.common.question.other": "питань",
"ui.common.add": "Додати",
"ui.common.back": "Назад",
"ui.common.cancel": "Скасувати",
"ui.common.confirm": "Підтвердити",
"ui.common.dismiss": "Відхилити",
"ui.common.close": "Закрити",
"ui.common.next": "Далі",
"ui.common.submit": "Надіслати",
"ui.permission.deny": "Заборонити",
"ui.permission.allowAlways": "Дозволяти завжди",
"ui.permission.allowOnce": "Дозволити один раз",
"ui.message.expand": "Розгорнути повідомлення",
"ui.message.collapse": "Згорнути повідомлення",
"ui.message.copy": "Копіювати",
"ui.message.copyMessage": "Копіювати повідомлення",
"ui.message.forkMessage": "Відгалузити в нову сесію",
"ui.message.revertMessage": "Скинути до цього моменту",
"ui.message.copyResponse": "Копіювати відповідь",
"ui.message.copied": "Скопійовано",
"ui.message.duration.seconds": "{{count}}с",
"ui.message.duration.minutesSeconds": "{{minutes}}хв {{seconds}}с",
"ui.message.interrupted": "Перервано",
"ui.message.queued": "У черзі",
"ui.message.attachment.alt": "вкладення",
"ui.patch.action.deleted": "Видалено",
"ui.patch.action.created": "Створено",
"ui.patch.action.moved": "Переміщено",
"ui.patch.action.patched": "Пропатчено",
"ui.question.subtitle.answered": "{{count}} відповідей",
"ui.question.answer.none": "(немає відповіді)",
"ui.question.review.notAnswered": "(не відповіли)",
"ui.question.multiHint": "Виберіть усі відповідні варіанти",
"ui.question.singleHint": "Виберіть одну відповідь",
"ui.question.custom.placeholder": "Введіть свою відповідь...",
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.15.4", "version": "1.15.5",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر الأول**، ثم **$10/شهريًا** — يمنحك وصولًا موثوقًا إلى نماذج البرمجة المفتوحة الشائعة. OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر الأول**، ثم **$10/شهريًا** — يمنحك وصولًا موثوقًا إلى نماذج البرمجة المفتوحة الشائعة.
:::note
OpenCode Go حاليًا في المرحلة التجريبية.
:::
يعمل Go مثل أي مزود آخر في OpenCode. تشترك في OpenCode Go وتحصل على مفتاح API الخاص بك. وهو **اختياري تمامًا**، ولا تحتاج إلى استخدامه لاستخدام OpenCode. يعمل Go مثل أي مزود آخر في OpenCode. تشترك في OpenCode Go وتحصل على مفتاح API الخاص بك. وهو **اختياري تمامًا**، ولا تحتاج إلى استخدامه لاستخدام OpenCode.
صُمّم أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر. صُمّم أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen هي قائمة بالنماذج التي اختبرها فريق OpenCode وتحقّق منها. OpenCode Zen هي قائمة بالنماذج التي اختبرها فريق OpenCode وتحقّق منها.
:::note
OpenCode Zen متاح حاليا بنسخة تجريبية.
:::
يعمل Zen مثل أي مزوّد آخر في OpenCode. تسجّل الدخول إلى OpenCode Zen وتحصل على يعمل Zen مثل أي مزوّد آخر في OpenCode. تسجّل الدخول إلى OpenCode Zen وتحصل على
مفتاح API. استخدامه **اختياري بالكامل** ولا تحتاج إليه لاستخدام OpenCode. مفتاح API. استخدامه **اختياري بالكامل** ولا تحتاج إليه لاستخدام OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go je povoljna pretplata — **$5 za vaš prvi mjesec**, a zatim **$10/mjesečno** — koja vam pruža pouzdan pristup popularnim otvorenim modelima za programiranje. OpenCode Go je povoljna pretplata — **$5 za vaš prvi mjesec**, a zatim **$10/mjesečno** — koja vam pruža pouzdan pristup popularnim otvorenim modelima za programiranje.
:::note
OpenCode Go je trenutno u beta fazi.
:::
Go radi kao bilo koji drugi provajder u OpenCode-u. Pretplatite se na OpenCode Go i Go radi kao bilo koji drugi provajder u OpenCode-u. Pretplatite se na OpenCode Go i
dobijete svoj API ključ. On je **potpuno opcionalan** i ne morate ga koristiti da dobijete svoj API ključ. On je **potpuno opcionalan** i ne morate ga koristiti da
biste koristili OpenCode. biste koristili OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen je lista testiranih i provjerenih modela koje pruža OpenCode tim. OpenCode Zen je lista testiranih i provjerenih modela koje pruža OpenCode tim.
:::note
OpenCode Zen je trenutno u beta fazi.
:::
Zen radi kao i svaki drugi provajder u OpenCode. Prijavite se na OpenCode Zen i Zen radi kao i svaki drugi provajder u OpenCode. Prijavite se na OpenCode Zen i
preuzmete svoj API ključ. To je **potpuno opcionalno** i ne morate ga koristiti preuzmete svoj API ključ. To je **potpuno opcionalno** i ne morate ga koristiti
da biste koristili OpenCode. da biste koristili OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go er et lavprisabonnement — **$5 for din første måned**, derefter **$10/måned** — der giver dig pålidelig adgang til populære åbne kodningsmodeller. OpenCode Go er et lavprisabonnement — **$5 for din første måned**, derefter **$10/måned** — der giver dig pålidelig adgang til populære åbne kodningsmodeller.
:::note
OpenCode Go er i øjeblikket i beta.
:::
Go fungerer som enhver anden udbyder i OpenCode. Du abonnerer på OpenCode Go og Go fungerer som enhver anden udbyder i OpenCode. Du abonnerer på OpenCode Go og
får din API-nøgle. Det er **helt valgfrit**, og du behøver ikke at bruge det for at får din API-nøgle. Det er **helt valgfrit**, og du behøver ikke at bruge det for at
bruge OpenCode. bruge OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen er en liste over testede og verificerede modeller leveret af OpenCode-teamet. OpenCode Zen er en liste over testede og verificerede modeller leveret af OpenCode-teamet.
:::note
OpenCode Zen er i øjeblikket i beta.
:::
Zen fungerer som enhver anden udbyder i OpenCode. Du logger ind på OpenCode Zen og får Zen fungerer som enhver anden udbyder i OpenCode. Du logger ind på OpenCode Zen og får
din API-nøgle. Det er **helt valgfrit**, og du behøver ikke bruge det for at bruge din API-nøgle. Det er **helt valgfrit**, og du behøver ikke bruge det for at bruge
OpenCode. OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go ist ein kostengünstiges Abonnement — **5 $ für deinen ersten Monat**, danach **10 $/Monat** —, das dir zuverlässigen Zugriff auf beliebte offene Coding-Modelle bietet. OpenCode Go ist ein kostengünstiges Abonnement — **5 $ für deinen ersten Monat**, danach **10 $/Monat** —, das dir zuverlässigen Zugriff auf beliebte offene Coding-Modelle bietet.
:::note
OpenCode Go befindet sich derzeit in der Beta-Phase.
:::
Go funktioniert wie jeder andere Provider in OpenCode. Du abonnierst OpenCode Go und Go funktioniert wie jeder andere Provider in OpenCode. Du abonnierst OpenCode Go und
erhältst deinen API-Key. Es ist **völlig optional** und du musst es nicht nutzen, um erhältst deinen API-Key. Es ist **völlig optional** und du musst es nicht nutzen, um
OpenCode zu verwenden. OpenCode zu verwenden.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen ist eine Liste von getesteten und verifizierten Modellen, die vom OpenCode-Team bereitgestellt wird. OpenCode Zen ist eine Liste von getesteten und verifizierten Modellen, die vom OpenCode-Team bereitgestellt wird.
:::note
OpenCode Zen befindet sich derzeit in der Beta.
:::
Zen funktioniert wie jeder andere Provider in OpenCode. Du meldest dich bei OpenCode Zen an und erhältst deinen API-Key. Es ist **vollständig optional** und du musst es nicht verwenden, um OpenCode zu nutzen. Zen funktioniert wie jeder andere Provider in OpenCode. Du meldest dich bei OpenCode Zen an und erhältst deinen API-Key. Es ist **vollständig optional** und du musst es nicht verwenden, um OpenCode zu nutzen.
--- ---
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go es una suscripción de bajo costo — **$5 por tu primer mes**, luego **$10/mes** — que te brinda acceso confiable a modelos abiertos de programación populares. OpenCode Go es una suscripción de bajo costo — **$5 por tu primer mes**, luego **$10/mes** — que te brinda acceso confiable a modelos abiertos de programación populares.
:::note
OpenCode Go está actualmente en beta.
:::
Go funciona como cualquier otro proveedor en OpenCode. Te suscribes a OpenCode Go y Go funciona como cualquier otro proveedor en OpenCode. Te suscribes a OpenCode Go y
obtienes tu API key. Es **completamente opcional** y no necesitas usarlo para obtienes tu API key. Es **completamente opcional** y no necesitas usarlo para
usar OpenCode. usar OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen es una lista de modelos probados y verificados proporcionada por el equipo de OpenCode. OpenCode Zen es una lista de modelos probados y verificados proporcionada por el equipo de OpenCode.
:::note
OpenCode Zen está actualmente en beta.
:::
Zen funciona como cualquier otro proveedor en OpenCode. Inicias sesión en OpenCode Zen y obtienes Zen funciona como cualquier otro proveedor en OpenCode. Inicias sesión en OpenCode Zen y obtienes
tu API key. Es **completamente opcional** y no necesitas usarlo para usar tu API key. Es **completamente opcional** y no necesitas usarlo para usar
OpenCode. OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go est un abonnement à bas coût — **5 $ pour votre premier mois**, puis **10 $/mois** — qui vous donne un accès fiable aux modèles de codage ouverts populaires. OpenCode Go est un abonnement à bas coût — **5 $ pour votre premier mois**, puis **10 $/mois** — qui vous donne un accès fiable aux modèles de codage ouverts populaires.
:::note
OpenCode Go est actuellement en version bêta.
:::
Go fonctionne comme n'importe quel autre fournisseur dans OpenCode. Vous vous abonnez à OpenCode Go et obtenez votre clé d'API. C'est **totalement facultatif** et vous n'avez pas besoin de l'utiliser pour utiliser OpenCode. Go fonctionne comme n'importe quel autre fournisseur dans OpenCode. Vous vous abonnez à OpenCode Go et obtenez votre clé d'API. C'est **totalement facultatif** et vous n'avez pas besoin de l'utiliser pour utiliser OpenCode.
Il est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Il est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen est une liste de modèles testés et vérifiés fournie par l'équipe OpenCode. OpenCode Zen est une liste de modèles testés et vérifiés fournie par l'équipe OpenCode.
:::note
OpenCode Zen est actuellement en version bêta.
:::
Zen fonctionne comme n'importe quel autre fournisseur dans OpenCode. Vous vous connectez à OpenCode Zen et obtenez votre clé API. C'est **entièrement facultatif** et vous n'avez pas besoin de l'utiliser pour utiliser OpenCode. Zen fonctionne comme n'importe quel autre fournisseur dans OpenCode. Vous vous connectez à OpenCode Zen et obtenez votre clé API. C'est **entièrement facultatif** et vous n'avez pas besoin de l'utiliser pour utiliser OpenCode.
--- ---
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go is a low cost subscription — **$5 for your first month**, then **$10/month** — that gives you reliable access to popular open coding models. OpenCode Go is a low cost subscription — **$5 for your first month**, then **$10/month** — that gives you reliable access to popular open coding models.
:::note
OpenCode Go is currently in beta.
:::
Go works like any other provider in OpenCode. You subscribe to OpenCode Go and Go works like any other provider in OpenCode. You subscribe to OpenCode Go and
get your API key. It's **completely optional** and you don't need to use it to get your API key. It's **completely optional** and you don't need to use it to
use OpenCode. use OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go è un abbonamento a basso costo — **5 $ per il primo mese**, poi **10 $/mese** — che ti offre un accesso affidabile ai popolari modelli di programmazione aperti. OpenCode Go è un abbonamento a basso costo — **5 $ per il primo mese**, poi **10 $/mese** — che ti offre un accesso affidabile ai popolari modelli di programmazione aperti.
:::note
OpenCode Go è attualmente in beta.
:::
Go funziona come qualsiasi altro provider in OpenCode. Ti abboni a OpenCode Go e Go funziona come qualsiasi altro provider in OpenCode. Ti abboni a OpenCode Go e
ottieni la tua chiave API. È **completamente facoltativo** e non hai bisogno di usarlo per ottieni la tua chiave API. È **completamente facoltativo** e non hai bisogno di usarlo per
utilizzare OpenCode. utilizzare OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen è un elenco di modelli testati e verificati forniti dal team di OpenCode. OpenCode Zen è un elenco di modelli testati e verificati forniti dal team di OpenCode.
:::note
OpenCode Zen è attualmente in beta.
:::
Zen funziona come qualsiasi altro provider in OpenCode. Accedi a OpenCode Zen e ottieni Zen funziona come qualsiasi altro provider in OpenCode. Accedi a OpenCode Zen e ottieni
la tua chiave API. È **completamente opzionale** e non ti serve usarlo per usare la tua chiave API. È **completamente opzionale** e non ti serve usarlo per usare
OpenCode. OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Goは低価格のサブスクリプションで、**初月は5ドル**、その後は**月額10ドル**で、人気のオープンなコーディングモデルに安定してアクセスできます。 OpenCode Goは低価格のサブスクリプションで、**初月は5ドル**、その後は**月額10ドル**で、人気のオープンなコーディングモデルに安定してアクセスできます。
:::note
OpenCode Goは現在ベータ版です。
:::
GoはOpenCodeの他のプロバイダーと同様に機能します。OpenCode GoをサブスクライブしてAPIキーを取得します。これは**完全に任意**であり、OpenCodeを使用するために必須ではありません。 GoはOpenCodeの他のプロバイダーと同様に機能します。OpenCode GoをサブスクライブしてAPIキーを取得します。これは**完全に任意**であり、OpenCodeを使用するために必須ではありません。
主に海外ユーザー向けに設計されており、世界中で安定してアクセスできるよう、モデルは米国、EU、シンガポールでホストされています。 主に海外ユーザー向けに設計されており、世界中で安定してアクセスできるよう、モデルは米国、EU、シンガポールでホストされています。
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen は、OpenCode チームが提供する、テスト済みかつ検証済みのモデルの一覧です。 OpenCode Zen は、OpenCode チームが提供する、テスト済みかつ検証済みのモデルの一覧です。
:::note
OpenCode Zen は現在ベータ版です。
:::
Zen は OpenCode のほかのプロバイダーと同じように動作します。OpenCode Zen にログインして API キーを取得できます。これは **完全にオプション** であり、OpenCode を使うために必須ではありません。 Zen は OpenCode のほかのプロバイダーと同じように動作します。OpenCode Zen にログインして API キーを取得できます。これは **完全にオプション** であり、OpenCode を使うために必須ではありません。
--- ---
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go는 인기 있는 오픈 코딩 모델에 안정적으로 액세스할 수 있게 해주는 저비용 구독 서비스입니다. **첫 달은 $5**, 이후에는 **월 $10**입니다. OpenCode Go는 인기 있는 오픈 코딩 모델에 안정적으로 액세스할 수 있게 해주는 저비용 구독 서비스입니다. **첫 달은 $5**, 이후에는 **월 $10**입니다.
:::note
OpenCode Go는 현재 베타입니다.
:::
Go는 OpenCode의 다른 제공자와 똑같이 작동합니다. OpenCode Go를 구독하고 API 키를 발급받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 꼭 필요하지는 않습니다. Go는 OpenCode의 다른 제공자와 똑같이 작동합니다. OpenCode Go를 구독하고 API 키를 발급받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 꼭 필요하지는 않습니다.
주로 해외 사용자를 위해 설계되었으며, 안정적인 전 세계 액세스를 위해 모델은 미국, EU, 싱가포르에 호스팅됩니다. 주로 해외 사용자를 위해 설계되었으며, 안정적인 전 세계 액세스를 위해 모델은 미국, EU, 싱가포르에 호스팅됩니다.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen은 OpenCode 팀이 제공하는, 테스트와 검증을 거친 모델 목록입니다. OpenCode Zen은 OpenCode 팀이 제공하는, 테스트와 검증을 거친 모델 목록입니다.
:::note
OpenCode Zen은 현재 beta입니다.
:::
Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. OpenCode Zen에 로그인해 API 키를 받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 반드시 사용할 필요는 없습니다. Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. OpenCode Zen에 로그인해 API 키를 받으면 됩니다. 이는 **완전히 선택 사항**이며, OpenCode를 사용하기 위해 반드시 사용할 필요는 없습니다.
--- ---
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go er et lavkostnadsabonnement — **$5 for din første måned**, deretter **$10/måned** — som gir deg pålitelig tilgang til populære åpne kodemodeller. OpenCode Go er et lavkostnadsabonnement — **$5 for din første måned**, deretter **$10/måned** — som gir deg pålitelig tilgang til populære åpne kodemodeller.
:::note
OpenCode Go er for øyeblikket i beta.
:::
Go fungerer som enhver annen leverandør i OpenCode. Du abonnerer på OpenCode Go og Go fungerer som enhver annen leverandør i OpenCode. Du abonnerer på OpenCode Go og
får din API-nøkkel. Det er **helt valgfritt**, og du trenger ikke å bruke det for å får din API-nøkkel. Det er **helt valgfritt**, og du trenger ikke å bruke det for å
bruke OpenCode. bruke OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen er en liste over testede og verifiserte modeller levert av OpenCode-teamet. OpenCode Zen er en liste over testede og verifiserte modeller levert av OpenCode-teamet.
:::note
OpenCode Zen er for øyeblikket i beta.
:::
Zen fungerer som enhver annen leverandør i OpenCode. Du logger inn på OpenCode Zen og får Zen fungerer som enhver annen leverandør i OpenCode. Du logger inn på OpenCode Zen og får
API-nøkkelen din. Det er **helt valgfritt**, og du trenger ikke bruke det for å bruke API-nøkkelen din. Det er **helt valgfritt**, og du trenger ikke bruke det for å bruke
OpenCode. OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Go to niskokosztowa subskrypcja — **5 $ za pierwszy miesiąc**, a następnie **10 $/miesiąc** — która zapewnia niezawodny dostęp do popularnych otwartych modeli do kodowania. OpenCode Go to niskokosztowa subskrypcja — **5 $ za pierwszy miesiąc**, a następnie **10 $/miesiąc** — która zapewnia niezawodny dostęp do popularnych otwartych modeli do kodowania.
:::note
OpenCode Go jest obecnie w fazie beta.
:::
Go działa jak każdy inny dostawca w OpenCode. Subskrybujesz OpenCode Go i Go działa jak każdy inny dostawca w OpenCode. Subskrybujesz OpenCode Go i
otrzymujesz swój klucz API. Jest to **całkowicie opcjonalne** i nie musisz z tego korzystać, aby otrzymujesz swój klucz API. Jest to **całkowicie opcjonalne** i nie musisz z tego korzystać, aby
używać OpenCode. używać OpenCode.
-4
View File
@@ -9,10 +9,6 @@ export const email = `mailto:${config.email}`
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniana przez zespół OpenCode. OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniana przez zespół OpenCode.
:::note
OpenCode Zen jest obecnie w wersji beta.
:::
Zen działa jak każdy inny dostawca w OpenCode. Logujesz się do OpenCode Zen i otrzymujesz Zen działa jak każdy inny dostawca w OpenCode. Logujesz się do OpenCode Zen i otrzymujesz
swój klucz API. To **całkowicie opcjonalne** i nie musisz z tego korzystać, aby używać swój klucz API. To **całkowicie opcjonalne** i nie musisz z tego korzystać, aby używać
OpenCode. OpenCode.

Some files were not shown because too many files have changed in this diff Show More