fix(opencode): fix free tier 403 error and improve China region handling

- Force stream:true and cloak decoy tools (bash, read) for OpenCode free tier
- Support connection testing for opencode in testUtils
- Expand error message slice limits in auth and ping to preserve workspace link
- Add concise China region link chip in provider detail page

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decolua
2026-09-18 16:57:06 +07:00
parent 52917a6d49
commit 93837af09f
6 changed files with 140 additions and 3 deletions

View File

@@ -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 });
}

View File

@@ -1783,7 +1783,33 @@ export default function ProviderDetailPage() {
})()}
</div>
{!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
<div className="mb-3">
<p className="text-xs text-red-500 break-words">{modelsTestError}</p>
{/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 (
<div className="mt-1.5">
<a
href={targetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-600 hover:bg-amber-500/20 dark:text-amber-400 transition-colors"
>
<span>Allow China-hosted models</span>
<span className="material-symbols-outlined text-[13px]">open_in_new</span>
</a>
</div>
);
})()}
</div>
)}
{providerId === "zed" && !!liveModelsError && (
<p className="text-xs text-red-500 mb-3 break-words">{liveModelsError}</p>

View File

@@ -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;

View File

@@ -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",

View File

@@ -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, {

View File

@@ -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");
});
});