diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 531c5df5..507c3718 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -24,6 +24,68 @@ export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +// OpenCode free tier requires both 'bash' and 'read' in tools payload. +// Injected as cloaked decoy tools so external CLI tools (e.g. Claude Code's Bash/Read) +// take precedence while satisfying upstream verification. +const OPENCODE_DECOY_CHAT_TOOLS = [ + { + type: "function", + function: { + name: "bash", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "read", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + }, +]; + +const OPENCODE_DECOY_RESPONSES_TOOLS = [ + { + type: "function", + name: "bash", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + { + type: "function", + name: "read", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, +]; + +function cloakOpencodeTools(body, isResponses) { + if (!body || typeof body !== "object") return; + if (isResponses) { + if (!Array.isArray(body.tools)) body.tools = []; + const names = new Set(body.tools.map((t) => t.name || t.function?.name)); + for (const tool of OPENCODE_DECOY_RESPONSES_TOOLS) { + if (!names.has(tool.name)) body.tools.push({ ...tool }); + } + if (!body.tool_choice) body.tool_choice = "auto"; + } else { + const hasTools = Array.isArray(body.tools) && body.tools.length > 0; + if (!hasTools) { + body.tools = OPENCODE_DECOY_CHAT_TOOLS.map((t) => ({ ...t, function: { ...t.function } })); + if (!body.tool_choice) body.tool_choice = "none"; + } else { + const names = new Set(body.tools.map((t) => t.function?.name || t.name)); + for (const tool of OPENCODE_DECOY_CHAT_TOOLS) { + if (!names.has(tool.function.name)) { + body.tools.push({ ...tool, function: { ...tool.function } }); + } + } + } + } +} + function hasValidOpencodeVersion(ua) { const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); if (!m) return false; @@ -410,6 +472,9 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { if (body && typeof body === "object" && model && !body.model) body.model = model; + // Zen rejects non-streaming requests on free models with 403 FreeTierError; + // always stream upstream and let the handler layer aggregate for non-stream clients. + if (body && typeof body === "object") body.stream = true; if (isResponsesModel(model || body?.model) && body && typeof body === "object") { // ponytail: chỉ model đã xác nhận auto-only; mở allowlist khi có bằng chứng. if ("tool_choice" in body && body.tool_choice !== "auto" @@ -434,6 +499,11 @@ export class OpenCodeExecutor extends BaseExecutor { body.store = false; normalizeResponsesTools(body); sanitizeResponsesItems(body); + if (!Array.isArray(body.tools) || body.tools.length === 0) { + cloakOpencodeTools(body, true); + } + } else if (body && typeof body === "object") { + cloakOpencodeTools(body, false); } return injectReasoningContent({ provider: this.provider, model, body }); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 437214e2..bae6ff05 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -1783,7 +1783,33 @@ export default function ProviderDetailPage() { })()} {!!modelsTestError && ( -

{modelsTestError}

+
+

{modelsTestError}

+ {/RegionError|hosted in China|regionNotAllowed/i.test(modelsTestError) && (() => { + const str = typeof modelsTestError === "string" ? modelsTestError : JSON.stringify(modelsTestError); + const linkMatch = str.match(/https:\/\/opencode\.ai\/workspace\/[^\s"')]+/); + const wrkMatch = str.match(/wrk_[0-9A-Za-z]+/); + const targetUrl = linkMatch + ? (linkMatch[0].endsWith("/go") ? linkMatch[0] : `${linkMatch[0]}/go`) + : wrkMatch + ? `https://opencode.ai/workspace/${wrkMatch[0]}/go` + : "https://opencode.ai"; + + return ( +
+ + Allow China-hosted models + open_in_new + +
+ ); + })()} +
)} {providerId === "zed" && !!liveModelsError && (

{liveModelsError}

diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 273aff47..a5c882d5 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -160,7 +160,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ if (!res.ok) { const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; - return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 500)}` : ""}`, status: res.status }; } const providerStatus = parsed?.status; diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 9572e69e..03500da2 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -751,6 +751,12 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const valid = !!(data && data.user); return { valid, error: valid ? null : "Session expired — re-paste cookie" }; } + case "opencode": { + const res = await fetchWithConnectionProxy("https://opencode.ai/zen/v1/models", { + headers: { Authorization: "Bearer public", "User-Agent": "opencode/1.18.31" }, + }, effectiveProxy); + return { valid: res.ok, error: res.ok ? null : "OpenCode free tier unavailable" }; + } case "opencode-go": { const res = await fetchWithConnectionProxy("https://opencode.ai/zen/go/v1/chat/completions", { method: "POST", diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index d85c4901..eff83e7c 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -263,7 +263,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr } if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; - const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; + const reason = typeof errorText === "string" ? errorText.slice(0, 200) : "Provider error"; const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs); await updateProviderConnection(connectionId, { diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index b7651f16..89e06a8f 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -276,4 +276,39 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => { expect(first).toMatch(OPENCODE_SESSION_RE); expect(second).toBe(first); }); + + it("cloaks free-tier requests with bash and read decoy tools", () => { + const executor = getExecutor("opencode"); + + // Case 1: no tools sent by client -> injects bash + read with tool_choice none + const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + }); + expect(chatNoTools.stream).toBe(true); + expect(chatNoTools.tool_choice).toBe("none"); + expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]); + + // Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read + const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }], + tool_choice: "auto", + }); + expect(chatWithTools.tool_choice).toBe("auto"); + const names = chatWithTools.tools.map((t) => t.function?.name); + expect(names).toContain("Bash"); + expect(names).toContain("bash"); + expect(names).toContain("read"); + + // Case 3: already has both bash and read -> do not insert anything + const chatFull = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [ + { type: "function", function: { name: "bash", description: "existing" } }, + { type: "function", function: { name: "read", description: "existing" } }, + ], + }); + expect(chatFull.tools.length).toBe(2); + expect(chatFull.tools[0].function.description).toBe("existing"); + }); });