适用版本: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 上游。

整体架构:

请求 /v1/chat/completions

补 User-Agent + x-opencode-* 头

200 响应

原样回传

VS Code Copilot Chat
chatLanguageModels.json

本地代理 zen-proxy.py
127.0.0.1:8788

opencode.ai/zen/v1
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 并验证

  1. Ctrl+Shift+PDeveloper: Reload Window
  2. 在 Chat 输入框的模型选择器里选 zen/deepseek-v4-flash-free
  3. 模型旁的 > 箭头打开 Thinking Effort 子菜单,可切换 Low/Medium/High/Max
  4. 随便发一条消息,能正常回复即成功

验证底层是否真的带了 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-proxysystemctl --user restart zen-proxysystemctl --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:apiKeypublic 安全吗?

公开节点就是这样设计的,无私有密钥;即便用私有 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 托管开机自启,无日志文件产生。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐