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}$/;
|
export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
||||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
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) {
|
function hasValidOpencodeVersion(ua) {
|
||||||
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
||||||
if (!m) return false;
|
if (!m) return false;
|
||||||
@@ -410,6 +472,9 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
if (body && typeof body === "object" && model && !body.model) body.model = model;
|
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") {
|
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.
|
// 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"
|
if ("tool_choice" in body && body.tool_choice !== "auto"
|
||||||
@@ -434,6 +499,11 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||||||
body.store = false;
|
body.store = false;
|
||||||
normalizeResponsesTools(body);
|
normalizeResponsesTools(body);
|
||||||
sanitizeResponsesItems(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 });
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1783,7 +1783,33 @@ export default function ProviderDetailPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{!!modelsTestError && (
|
{!!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 && (
|
{providerId === "zed" && !!liveModelsError && (
|
||||||
<p className="text-xs text-red-500 mb-3 break-words">{liveModelsError}</p>
|
<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) {
|
if (!res.ok) {
|
||||||
const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText;
|
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;
|
const providerStatus = parsed?.status;
|
||||||
|
|||||||
@@ -751,6 +751,12 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
|||||||
const valid = !!(data && data.user);
|
const valid = !!(data && data.user);
|
||||||
return { valid, error: valid ? null : "Session expired — re-paste cookie" };
|
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": {
|
case "opencode-go": {
|
||||||
const res = await fetchWithConnectionProxy("https://opencode.ai/zen/go/v1/chat/completions", {
|
const res = await fetchWithConnectionProxy("https://opencode.ai/zen/go/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
|||||||
}
|
}
|
||||||
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
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);
|
const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
|
||||||
|
|
||||||
await updateProviderConnection(connectionId, {
|
await updateProviderConnection(connectionId, {
|
||||||
|
|||||||
@@ -276,4 +276,39 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => {
|
|||||||
expect(first).toMatch(OPENCODE_SESSION_RE);
|
expect(first).toMatch(OPENCODE_SESSION_RE);
|
||||||
expect(second).toBe(first);
|
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