mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e726761e2 | |||
| 2932a7a35d | |||
| 922b2e10eb | |||
| a2ee437a0e | |||
| 6618e2bce2 | |||
| cb15b3ad84 | |||
| ee16f08ffa | |||
| 9790a61f96 | |||
| 2339aacbf2 | |||
| 2d348da504 | |||
| cbd2620c46 | |||
| 3bd304796b | |||
| dac81cdb68 | |||
| 2c3bcf3e41 | |||
| d9d43d8519 | |||
| bcc69f0afb | |||
| e53563f402 | |||
| 7e4b02f1bf | |||
| 996928b32a | |||
| c032e821bc | |||
| 6f160bb48f | |||
| ebb672ac39 | |||
| 9a19e84265 | |||
| 338666d13e | |||
| 7b8a1037a0 | |||
| ee5cf45ef9 | |||
| 2e1593dea5 | |||
| 0f3d168fdd | |||
| ce09fc8356 | |||
| 44a35c5895 | |||
| 8a321c4536 | |||
| ef9e567e5f | |||
| b396b71c6f | |||
| d8efc575fa | |||
| e1fbed8fb6 | |||
| 12ae22378f | |||
| a88b436eec | |||
| dbe36851bc |
@@ -14,3 +14,4 @@ rekram1-node
|
|||||||
thdxr
|
thdxr
|
||||||
simonklee
|
simonklee
|
||||||
vimtor
|
vimtor
|
||||||
|
starptech
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { test } from "@playwright/test"
|
|
||||||
|
|
||||||
test(
|
|
||||||
"test something cool",
|
|
||||||
{
|
|
||||||
annotation: { type: "todo" },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
test.fixme()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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,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,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,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,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,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) }
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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,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,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(
|
||||||
|
|||||||
+49
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,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,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,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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,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,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": {
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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": "Введіть свою відповідь...",
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
صُمّم أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر.
|
صُمّم أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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、シンガポールでホストされています。
|
||||||
|
|||||||
@@ -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 を使うために必須ではありません。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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, 싱가포르에 호스팅됩니다.
|
||||||
|
|||||||
@@ -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를 사용하기 위해 반드시 사용할 필요는 없습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user