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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user