在 VS Code 里免费使用 opencode ZEN 模型节点(deepseek-v4-flash-free)完整指南
适用版本:VS Code 1.9x+ / Copilot Chat;opencode 1.14.x;系统:Linux(Ubuntu/Debian 系)
更新时间:2026-08-16
零、前提
首先要会使用copilot的Custom Endpoint或oaicopilot插件配置,二者选其一即可。该文章以Custom Endpoint为例进行讲解。关于Custom Endpoint的配置,可以参考我另一篇文章VS Code 通过 Custom Endpoint 接入第三方模型
一、背景与痛点
opencode 提供了免费的 ZEN 节点,端点地址为:
https://opencode.ai/zen/v1
其中包含一些免费模型(如 deepseek-v4-flash-free),官方在 opencode 客户端里可以直接选用。但当我们想把免费模型接入 VS Code 的 Copilot Chat(通过"自定义端点/自带模型"功能)时,会遇到一个非常典型的报错:
Sorry, your request failed. Please try again.
Client Request Id: xxxx
Reason: Rate limit exceeded
{"type":"FreeUsageLimitError","message":"Error from provider (Console): Rate limit exceeded. Please try again later."}
配置里 API Key 写 public、URL 写 https://opencode.ai/zen/v1 都是对的,但请求就是被 429 拦截。为什么 opencode 客户端能用、VS Code 不能用?答案在请求头里。
二、根因:服务端靠请求头识别客户端
通过抓包/实测(curl 直连对比)可以得到以下结论:
| 请求头组合 | 结果 |
|---|---|
裸请求(只带 Authorization: Bearer public) |
❌ 429 限流 |
带 x-opencode-client / x-opencode-project 等头,没有 UA |
❌ 429 限流 |
带 x-opencode-* 头 + User-Agent: opencode/... |
✅ 200 正常 |
也就是说:ZEN 服务端会把请求头中的 User-Agent 和 x-opencode-* 头当作"客户端指纹",只有看起来像 opencode 客户端的请求才进入免费通道,否则全部丢进共享限流池返回 429。
而 VS Code 的"自定义端点"(Custom Endpoint)在处理 requestHeaders 时,会过滤掉 User-Agent 等保留头。也就是说,不管你如何在 chatLanguageModels.json 里写 "User-Agent": "opencode/...",这个头根本不会被发出去(源码里 _reservedHeaders 包含 user-agent,_sanitizeCustomHeaders 会直接跳过)。
补充:曾经用 API Key(
sk-...)测试也是 429,和认证方式无关,纯粹是"客户端指纹"缺失。
三、解决思路:本地小代理补头转发
既然 VS Code 不让我们改 User-Agent,那就让 VS Code 把请求发给本地代理,由代理负责补上所有 opencode 头,再转发到 ZEN 上游。
整体架构:
代理只监听 127.0.0.1,仅本机可访问,不会暴露到局域网。
四、实施步骤
第 1 步:编写本地代理脚本
新建 /home/你的用户名/zen-proxy.py,内容如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
zen-proxy.py - ZEN (opencode.ai) 免费模型本地代理
背景:
VS Code 的 customendpoint 会把 requestHeaders 中的 User-Agent 当作保留头过滤掉,
而 ZEN 服务端 (opencode.ai/zen/v1) 必须看到 opencode 客户端的 User-Agent 和
x-opencode-* 头才放行免费额度, 否则返回 429 FreeUsageLimitError。
方案:
在本地监听一个端口, VS Code 的模型 URL 指向本代理;
代理收到请求后, 补上 opencode 客户端头(每次请求生成新的 session/request id),
再原样转发到 https://opencode.ai/zen/v1。
增强:
为解决 thinking 模式下上游要求回传历史 reasoning_content 的 400 错误,
代理现在为每个会话缓存 assistant 响应的 reasoning_content,
在转发下一轮请求前按顺序回填到历史 assistant 消息中。
用法:
python3 zen-proxy.py [--port 8788]
配合 chatLanguageModels.json:
{
"name": "ZEN",
"vendor": "customendpoint",
"apiKey": "public",
"apiType": "chat-completions",
"models": [{
"id": "deepseek-v4-flash-free",
"name": "zen/deepseek-v4-flash-free",
"url": "http://127.0.0.1:8788/v1",
"toolCalling": true,
"vision": true,
"maxInputTokens": 200000,
"maxOutputTokens": 4096
}]
}
"""
import argparse
import json
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen, HTTPError
UPSTREAM = "https://opencode.ai/zen/v1"
USER_AGENT = "opencode/1.14.28 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13"
# 需要透传但由代理统一生成的头(防止 VS Code 的固定值被服务端拿去限流)
DYN_HEADERS = {
"User-Agent": USER_AGENT,
"x-opencode-client": "cli",
"x-opencode-project": "global",
}
# 会话级 reasoning_content 缓存: session_id -> deque[(assistant_msg_index, reasoning_content)]
_rc_cache = {}
_rc_lock = threading.Lock()
_MAX_CACHE_PER_SESSION = 32 # 每会话最多缓存最近 N 轮,防止无限增长
def _extract_session_id(headers):
"""从请求头提取会话 ID,用于分组缓存"""
return headers.get("x-opencode-session")
def _backfill_reasoning_content(messages, session_id):
"""
按顺序将缓存中的 reasoning_content 回填到 assistant 消息中。
只处理 role == "assistant" 且没有 reasoning_content 字段的消息。
返回实际回填的条目数。
"""
if not session_id or not messages:
return 0
with _rc_lock:
cache_deque = _rc_cache.get(session_id)
if not cache_deque:
return 0
filled = 0
# 遍历消息,按顺序回填缓存中的 reasoning_content
for msg in messages:
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
if cache_deque:
idx, rc_text = cache_deque.popleft()
msg["reasoning_content"] = rc_text
filled += 1
else:
break # 缓存用完,停止回填
# 如果缓存 deque 为空,删除该会话条目以避免内存泄漏
if not cache_deque:
del _rc_cache[session_id]
return filled
def _cache_reasoning_content(session_id, text):
"""
将 reasoning_content 文本缓存到会话对应的 deque 中。
保持最近 _MAX_CACHE_PER_SESSION 条。
"""
if not session_id or not text:
return
with _rc_lock:
if session_id not in _rc_cache:
_rc_cache[session_id] = deque(maxlen=_MAX_CACHE_PER_SESSION)
# 使用当time.time_ns()生成索引以保持插入顺序,实际索引值不重要
_rc_cache[session_id].append((time.time_ns(), text))
def _parse_sse_line(line):
"""
解析 SSE 行,返回 JSON 数据(如果是以 'data: ' 开头的有效行)。
返回 None 表示非数据行或解析失败。
"""
if not line.startswith("data: "):
return None
data_str = line[6:] # 去掉 "data: " 前缀
if data_str.strip() == "[DONE]":
return None
try:
return json.loads(data_str)
except json.JSONDecodeError:
return None
class ZenProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b""
# ---- 调试日志: 打印 VS Code 实际发送的请求体结构 ----
path = self.path
try:
j = json.loads(body)
msgs = j.get("messages", [])
desc = []
for m in msgs:
role = m.get("role")
c = m.get("content")
rc = m.get("reasoning_content")
tc = m.get("tool_calls")
item = f"{role}"
if isinstance(c, str):
item += f"[c:{len(c)}]"
elif isinstance(c, list):
item += f"[c:list{len(c)}]"
item += f"[rc:{'Y' if rc else 'n'}]"
item += f"[tc:{'Y' if tc else 'n'}]"
desc.append(item)
print(f"[zen-proxy] {self.command} {path} model={j.get('model')} "
f"effort={j.get('reasoning_effort')} stream={j.get('stream')} "
f"msgs({len(msgs)}): {' '.join(desc)}", flush=True)
# 打印完整请求体(截断长 content), 便于抓取 assistant 消息的所有字段
dbg = json.dumps(j, ensure_ascii=False)
print(f"[zen-proxy] BODY: {dbg[:4000]}", flush=True)
except Exception:
print(f"[zen-proxy] {self.command} {path} body={body[:500]!r}", flush=True)
# 提取会话 ID(代理生成的头)
session_id = self.headers.get("x-opencode-session")
# 在转发请求前,回填缓存的 reasoning_content
try:
if isinstance(j, dict) and "messages" in j:
filled_count = _backfill_reasoning_content(j["messages"], session_id)
if filled_count > 0:
# 重新编码请求体
body = json.dumps(j, ensure_ascii=False).encode("utf-8")
print(f"[zen-proxy] backfilled {filled_count} reasoning_content for session {session_id[:16]}...", flush=True)
except Exception as e:
# 降级:回填失败不影响原有转发逻辑
print(f"[zen-proxy] backfill error: {e}", flush=True)
# 上游路径: VS Code 会请求 /v1/chat/completions, 剥掉 /v1 前缀
path = self.path
if path.startswith("/v1/"):
path = path[len("/v1"):]
upstream_url = UPSTREAM + path
req = Request(upstream_url, data=body, method=self.command)
# 透传客户端头(保留 Authorization: Bearer public 等)
for k, v in self.headers.items():
if k.lower() in ("host", "connection", "content-length",
"transfer-encoding", "user-agent",
"x-opencode-client", "x-opencode-project",
"x-opencode-session", "x-opencode-request"):
continue
req.add_header(k, v)
# 生成 opencode 客户端头: 每次请求唯一 id, 避免共享限流池按 id 计数
ts = int(time.time() * 1000)
for k, v in DYN_HEADERS.items():
req.add_header(k, v)
req.add_header("x-opencode-session", f"ses_vscode_{ts}")
req.add_header("x-opencode-request", f"msg_vscode_{ts}")
try:
resp = urlopen(req, timeout=300)
status = resp.status
resp_headers = dict(resp.headers.items())
# 处理响应:根据内容类型决定是否解析 SSE 以缓存 reasoning_content
content_type = resp_headers.get("Content-Type", "")
is_sse = "text/event-stream" in content_type
if is_sse:
# SSE 流模式:逐行读取、解析 reasoning_content 并原样转发
data = b""
try:
while True:
line = resp.readline()
if not line: # 连接关闭
break
data += line
# 尝试解析 SSE 行以提取 reasoning_content
try:
line_decoded = line.decode("utf-8", errors="ignore").rstrip("\n\r")
parsed = _parse_sse_line(line_decoded)
if parsed and isinstance(parsed, dict):
choices = parsed.get("choices", [])
if choices and isinstance(choices, list):
delta = choices[0].get("delta", {}) if isinstance(choices[0], dict) else {}
if isinstance(delta, dict):
rc = delta.get("reasoning_content")
if rc is not None and isinstance(rc, str) and session_id:
_cache_reasoning_content(session_id, rc)
# 注意:这里不打印日志以避免过多输出,必要时可启用调试
# print(f"[zen-proxy] cached reasoning_content len={len(rc)} for session {session_id[:16]}...", flush=True)
except Exception:
# SSE 行解析失败不影响转发
pass
except Exception as e:
# 读取过程中出错,仍将已读数据转发
print(f"[zen-proxy] SSE read error: {e}", flush=True)
else:
# 非 SSE 模式:读取全部响应,尝试从 JSON 中提取 reasoning_content 缓存
data = resp.read()
try:
resp_json = json.loads(data.decode("utf-8", errors="replace"))
choices = resp_json.get("choices", [])
if choices and isinstance(choices, list):
message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
if isinstance(message, dict):
rc = message.get("reasoning_content")
if rc is not None and isinstance(rc, str) and session_id:
_cache_reasoning_content(session_id, rc)
# print(f"[zen-proxy] cached reasoning_content len={len(rc)} from non-SSE for session {session_id[:16]}...", flush=True)
except Exception:
# JSON 解析失败不影响转发
pass
print(f"[zen-proxy] <- {status} ({len(data)}B)", flush=True)
except HTTPError as e:
status = e.code
resp_headers = dict(e.headers.items())
data = e.read()
print(f"[zen-proxy] <- UPSTREAM ERROR {status}: {data[:1500]!r}", flush=True)
except Exception as e: # noqa: BLE001
status = 502
resp_headers = {}
data = str(e).encode("utf-8", errors="replace")
print(f"[zen-proxy] <- LOCAL ERROR 502: {data[:800]!r}", flush=True)
self.send_response(status)
for k, v in resp_headers.items():
if k.lower() in ("transfer-encoding", "connection", "content-encoding"):
continue
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = do_OPTIONS = do_PUT = do_DELETE = _handle
def log_message(self, fmt, *args):
pass # 静默
def main():
ap = argparse.ArgumentParser(description="ZEN 免费模型本地代理")
ap.add_argument("--port", type=int, default=8788)
args = ap.parse_args()
server = ThreadingHTTPServer(("127.0.0.1", args.port), ZenProxyHandler)
print(f"[zen-proxy] listening on http://127.0.0.1:{args.port} -> {UPSTREAM}")
print("[zen-proxy] Reasoning content caching enabled for thinking mode compatibility")
print("[zen-proxy] Ctrl+C 停止。")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[zen-proxy] 已停止")
if __name__ == "__main__":
main()
说明:
x-opencode-session/x-opencode-request每次都生成唯一值,避免多个请求共用同一个 id 被服务端限流池计数。
第 2 步:启动代理并自测
python3 /home/你的用户名/zen-proxy.py --port 8788
另开一个终端自测:
curl -s --max-time 60 "http://127.0.0.1:8788/v1/chat/completions" \
-H "Authorization: Bearer public" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"say ok"}],"max_tokens":20}'
返回 200 且带 choices 字段即成功:
{"id":"router-xxx","object":"chat.completion","model":"deepseek-v4-flash-free",
"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}], ...}
第 3 步:配置 VS Code 自定义端点
编辑 VS Code 用户配置文件 chatLanguageModels.json:
打开方式:
Ctrl+Shift+P→ 输入 Manage Language Models → 添加 Custom Endpoint;或直接编辑~/.config/Code/User/chatLanguageModels.json
在数组里加入:
{
"name": "ZEN",
"vendor": "customendpoint",
"apiKey": "public",
"apiType": "chat-completions",
"models": [
{
"id": "deepseek-v4-flash-free",
"name": "zen/deepseek-v4-flash-free",
"url": "http://127.0.0.1:8788/v1",
"toolCalling": true,
"vision": true,
"maxInputTokens": 200000,
"maxOutputTokens": 4096,
"thinking": true,
"supportsReasoningEffort": ["low", "medium", "high", "max"],
"reasoningEffortFormat": "chat-completions"
}
],
"settings": {
"deepseek-v4-flash-free": {
"reasoningEffort": "max"
}
}
}
字段含义:
| 字段 | 值 | 说明 |
|---|---|---|
apiKey |
public |
ZEN 免费节点的公共令牌,无需注册 |
url |
http://127.0.0.1:8788/v1 |
指向本地代理(注意不要写 https://opencode.ai) |
apiType |
chat-completions |
走 Chat Completions 协议 |
thinking |
true |
声明模型支持推理 |
supportsReasoningEffort |
["low","medium","high","max"] |
在模型选择器里显示 Thinking Effort 四级 |
reasoningEffortFormat |
chat-completions |
以顶层 reasoning_effort 字段发送 |
settings(provider 级) |
reasoningEffort: "max" |
默认推理努力,新会话/重开不重置 |
第 4 步:重载 VS Code 并验证
Ctrl+Shift+P→ Developer: Reload Window- 在 Chat 输入框的模型选择器里选
zen/deepseek-v4-flash-free - 模型旁的
>箭头打开 Thinking Effort 子菜单,可切换 Low/Medium/High/Max - 随便发一条消息,能正常回复即成功
验证底层是否真的带了 reasoning_effort:临时在代理里加一行日志(转发前打印请求体),应能看到:
{"model": "deepseek-v4-flash-free", "reasoning_effort": "max", ...}
五、(可选)配置开机自启
用 systemd 用户服务托管代理:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/zen-proxy.service << 'EOF'
[Unit]
Description=ZEN free model proxy (opencode.ai)
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/你的用户名/zen-proxy.py --port 8788
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now zen-proxy
说明:
enable后登录桌面即自动启动,无需手动操作;- 如果希望"开机未登录也运行"(如远程 SSH 使用),需额外执行
sudo loginctl enable-linger 你的用户名,一般场景不需要; - 常用管理命令:
systemctl --user status zen-proxy、systemctl --user restart zen-proxy、systemctl --user stop zen-proxy。
六、常见问题 FAQ
Q1:为什么直接配 https://opencode.ai/zen/v1 会被 429?
服务端按请求头识别客户端,裸请求 / 非 opencode UA 的请求全部丢进共享限流池。必须通过代理补 User-Agent: opencode/... 和 x-opencode-* 头。
Q2:为什么不用 requestHeaders 在 VS Code 里直接加 User-Agent?
VS Code 的 Custom Endpoint 会把 User-Agent 等保留头从 requestHeaders 中过滤掉,配置了也不会发出去,这是源码层面的安全限制。
Q3:apiKey 用 public 安全吗?
公开节点就是这样设计的,无私有密钥;即便用私有 sk- 密钥,不满足头指纹条件同样 429,所以没有意义。
Q4:免费额度用完了怎么办?
FreeUsageLimitError 是全局共享限流,等一段时间(几分钟到几小时)会自动恢复;代理每次生成新的 session/request id 就是为了尽量避开按 id 的计数。
Q5:模型列表里还有其他免费模型吗?
可以请求 http://127.0.0.1:8788/v1/models 查看当前节点支持的所有模型(包含付费与免费),免费模型一般以 -free 结尾。
七、小结
一句话总结整个方案:
VS Code 无法自定义 User-Agent,而 ZEN 免费节点只认"opencode 客户端指纹"——通过一个 30 行的本地 Python 代理补头转发即可绕过 429,把 deepseek-v4-flash-free 免费模型接入 Copilot Chat,还支持 Thinking Effort(Low/Medium/High/Max)四级推理。
本方案实测:请求全部 200,推理档位 max 在底层请求中确认生效,代理 systemd 托管开机自启,无日志文件产生。
更多推荐

所有评论(0)