把一个订阅制的 AI 编码产品(Claude Code、Codex、Qoder 这类)的额度,转成 OpenAI 兼容的 /v1/chat/completions 端点,再分发给任意支持 OpenAI 协议的客户端——这是 9router 这类「AI 路由器」解决的核心问题。它本质上是一个协议网关:对上抹平各家厂商的鉴权差异,对下抹平各家响应格式的差异。

Qoder 是一个很好的解剖样本。它不是 OpenAI 协议,也不是 Anthropic 协议,而是自己的一套:device-token 鉴权、COSY 签名、agent_common 服务端 agent、SSE 流式响应。把 Qoder 接到 9router 上,再把 Claude Code 接到 9router 上,中间这层协议穿透的每一跳都值得拆开看。这篇文章以 9router 0.5.40 的实际构建产物为依据,逐层分析鉴权、签名、配额、多账户调度、协议翻译五个机制,并回答一个关键问题:经过这样一层代理后,原订阅的 harness 工程会如何影响实际使用效果。

鉴权层:Device Code Flow 与不可刷新的 30 天 Token

Qoder 不支持用户名密码,也不支持 Authorization Code Flow,只用 Device Code Flow。9router 的实现位于 app/src/lib/oauth/services/qoder.js,核心是 initiateDeviceFlowpollDeviceToken 两个方法。

整个流程从本地生成三组随机值开始:

// app/src/lib/oauth/services/qoder.js
generatePkcePair() {
  const verifier = base64Url(crypto.randomBytes(32));
  const challenge = base64Url(
    crypto.createHash("sha256").update(verifier).digest()
  );
  return { verifier, challenge };
}

initiateDeviceFlow() {
  const { verifier, challenge } = this.generatePkcePair();
  const nonce = uuidv4();        // 设备码本身
  const machineId = uuidv4();    // 后续 COSY 签名要用
  const params = new URLSearchParams({
    challenge, challenge_method: "S256",
    machine_id: machineId, nonce,
  });
  return { verificationUriComplete: `${QODER_LOGIN_URL}?${params}`,
           codeVerifier: verifier, nonce, machineId };
}

注意 nonce 同时承担两个角色:既是 device code(拿去拼授权 URL),又是后续轮询的凭据。pollDeviceTokennonce + codeVerifier 去轮询 https://openapi.qoder.sh/api/v1/deviceToken/poll,状态码语义被设计得很有意思——202 和 404 都表示「继续等」:

// 202/404 = 用户还没在浏览器授权,继续轮询
if (response.status === 202 || response.status === 404) {
  return { status: "pending" };
}
// 200 + body.token = 授权完成,拿到 dt- 开头的 access token

拿到 token 后,9router 调 /api/v1/userinfouserIdorganizationId,把整套凭据作为一条 connection 存进本地 SQLite(Windows 下位于 %APPDATA%/9router/db/data.sqlite)。其中 userIdmachineId 是后续发请求的关键,accessToken 约 30 天后过期。

这里有一个工程上很关键的细节:Qoder 在配置里被标了 refreshable: falserefreshAndUpdateCredentials 函数会调用 provider 的 refreshCredentials,而 Qoder executor 直接返回 null

// chunks/318.js, QoderExecutor
async refreshCredentials() { return null; }
needsRefresh() { return false; }

这意味着 Qoder 的 token 是一次性资产,过期后没有 silent refresh,只能让用户重新走一遍 device flow。这与 Claude、Codex 的 OAuth refresh 机制形成鲜明对比——后两者在 expiresAt 临近时会自动用 refresh token 续期,对用户透明。

签名层:COSY 协议的三重密码学组合

Qoder 真正发聊天请求的端点不是 REST API,而是一个 SSE 端点:

https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation
  ?FetchKeys=llm_model_result&AgentId=agent_common

AgentId=agent_common 这个参数已经透露了:上游是一个 agent,不是裸 completion。要打这个端点,光有 access token 不够,还得过一道 COSY 签名。COSY 是 Qoder 自有的请求签名协议,9router 在 chunks/203.js 模块 30015 里实现了完整的签名函数,它组合了 RSA、AES-CBC、MD5 三种密码学原语:

// chunks/203.js, COSY 签名核心逻辑(还原后)
function sign(body, url, creds) {
  const userId = creds.userId;
  const authToken = creds.authToken;

  // 1. 构造用户信息 JSON,用随机 16 字节 AES key 加密
  const aesKey = randomUUID().slice(0, 16);
  const userInfo = JSON.stringify({
    uid: userId, security_oauth_token: authToken,
    name: creds.name, aid: "", email: creds.email,
  });
  const aesCipher = aes128CbcEncrypt(userInfo, aesKey);  // CBC, PKCS7 填充

  // 2. 用 Qoder 内置 RSA 公钥加密 AES key,得 cosyKey
  const cosyKey = rsaPublicEncrypt(RSA_PUBKEY, aesKey).toString("base64");

  // 3. 构造签名材料:base64(请求头信息) + cosyKey + 时间戳 + body + url路径
  const headerInfo = base64(JSON.stringify({
    version: "v1", requestId: randomUUID(), info: aesCipher,
    cosyVersion: "1.0.0", ideVersion: "",
  }));
  const sigPath = extractPathAfterAlgo(url);  // 去掉 /algo 前缀
  const signingString = `${headerInfo}\n${cosyKey}\n${timestamp}\n${body}\n${sigPath}`;
  const signature = md5(signingString);  // MD5 作为签名摘要

  return {
    Authorization: `Bearer COSY.${headerInfo}.${signature}`,
    "Cosy-Key": cosyKey,
    "Cosy-User": userId,
    "Cosy-Date": timestamp,
    "Cosy-Machineid": machineId,
    "Cosy-Machinetoken": machineId,
    "Cosy-Machinetype": "5",
    "Cosy-Machineos": "x86_64_windows",
    "Cosy-Clienttype": "5",
    "Cosy-Bodyhash": md5(body),
    "Cosy-Bodylength": String(body.length),
    "Cosy-Sigpath": sigPath,
    // ...
  };
}

整个签名的设计意图很清楚:用对称密钥(AES)加密敏感的用户凭据,再用非对称密钥(RSA)保护对称密钥的传输,最后用 MD5 做完整性校验。这是一种典型的「混合加密 + 签名」模式,常见于需要防止重放和篡改的私有 API。RSA 公钥是硬编码在 9router 里的 1024 位密钥,这意味着 9router 是把 Qoder 客户端整套签名逻辑逆出来后移植的——任何一个字段填错(比如 Cosy-Sigpath 路径处理不对),上游直接 401。

值得注意的还有 Cosy-MachineidCosy-Machinetoken 被设成同一个值。这个 machineId 来自 OAuth 阶段生成的 UUID,会随 connection 持久化。也就是说,同一个 Qoder 账号在 9router 里始终用同一个伪造的 machineId 上报,对上游而言这台「设备」是稳定的。

配额层:按需拉取与 auto-ping 的覆盖盲区

代理多个账户时,配额查询是调度的前提。9router 对配额的处理分两个层面,但这两个层面在 Qoder 上是不对称的。

按需拉取/api/usage/[connectionId] 路由(chunks/7211.js 模块 18271)。它先尝试刷新凭据(Qoder 跳过),再用一个分发函数 W() 把请求路由到对应 provider 的 usage handler。Qoder 对应的 handler 是函数 U

// chunks/7211.js, Qoder 配额查询(还原后)
async function fetchQoderUsage(accessToken, proxyOptions) {
  const res = await proxyAwareFetch(
    "https://openapi.qoder.sh/api/v2/quota/usage",
    { method: "GET",
      headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" } },
    proxyOptions
  );
  const json = await res.json();
  return {
    quotas: {
      user:         { total: json.userQuota.total, used: json.userQuota.used,
                      remaining: json.userQuota.remaining, unit: "credits", resetAt },
      organization: { /* 同结构,来自 json.orgResourcePackage */ },
    },
    totalUsagePercentage: json.totalUsagePercentage,
    isQuotaExceeded: !!json.isQuotaExceeded,
    expiresAt: json.expiresAt,
  };
}

返回结构里 userorganization 两级配额分得很清楚——Qoder 的额度模型区分个人额度和组织额度,调度时两者都要看。

auto-ping 保活 是另一个机制,位于 app/src/shared/services/quotaAutoPing.js。它的设计目的是在 5 小时窗口刚重置时发一个 max_tokens: 1 的极小请求去「预热」窗口,防止闲置导致窗口浪费。调度器每 60 秒跑一次 tick,但 tick 里能处理的 provider 是硬编码的:

// app/src/shared/services/quotaAutoPing.js
const providerHandlers = {
  claude: { getUsage: getClaudeUsage, sendPing: sendClaudePing },
  codex:  { getUsage: getCodexUsage,  sendPing: sendCodexPing  },
  // 没有 qoder
};

配置表 QUOTA_AUTOPING_CONFIG.providerschunks/869.js)也只列了 claude 和 codex。这是一个明确的覆盖盲区:Qoder 不会自动保活,也不会被定时轮询余额。 面板上看到的 Qoder 余额是每次打开 Usage 页时现拉一次。

如果要做多账户余额监控,必须自己写脚本定时调 /api/usage/<connectionId>。这与 Claude/Codex 的「自动 ping + 自动刷新余额」体验差距很大,是 Qoder 接入的固有成本。

多账户调度:三种策略与粘性轮换

把多个 Qoder 账号都接进来后,每条 connection 在同一个 qoder provider 下。请求到达时,9router 用一个连接选择函数决定用哪条 connection(chunks/4664.js)。策略配置存在 providerStrategies.qoder.fallbackStrategy 里,三选一:

// chunks/4664.js, 连接选择核心(还原后)
const strategy = providerStrategies["qoder"]?.fallbackStrategy
              || settings.fallbackStrategy
              || "fill-first";  // 默认填满优先

if (strategy === "round-robin") {
  const stickyLimit = providerStrategies["qoder"]?.stickyRoundRobinLimit
                   || settings.stickyRoundRobinLimit || 3;

  // 按 lastUsedAt 降序排,取最近用过的
  const recent = [...connections].sort(byLastUsedAtDesc)[0];
  const consecutive = recent?.consecutiveUseCount || 0;

  if (recent && consecutive < stickyLimit) {
    // 还没到粘性上限,继续用这条
    selected = recent;
    await updateProviderConnection(selected.id, {
      lastUsedAt: now(),
      consecutiveUseCount: consecutive + 1,
    });
  } else {
    // 到上限了,切到最久没用的那条
    selected = [...connections].sort(byLastUsedAtAsc)[0];
    await updateProviderConnection(selected.id, {
      lastUsedAt: now(),
      consecutiveUseCount: 1,
    });
  }
} else if (strategy === "random") {
  selected = connections[Math.floor(Math.random() * connections.length)];
} else {
  // fill-first: 永远优先用排在最前面的可用账户,用满才换
  selected = connections[0];
}

这里的关键设计是 sticky round-robin。纯轮换(每次请求换一条)听起来最均衡,但在 LLM 场景下有个问题:很多上下文是会话连续的,频繁换账户会让上游的会话状态完全断裂。9router 的解法是连续用同一账户 N 次(stickyRoundRobinLimit,默认 3)再切到下一条,在均衡和连续性之间取折中。

被限流或余额耗尽的账户会被自动跳过。选择函数开头有一段锁定检测:如果某条 connection 的 lastError 指向 429/403 且未到 retryAfter,它会被排除在候选集之外。当所有候选都锁定时,返回 allRateLimited: trueretryAfter,9router 对客户端返回 429。这套机制让多账户轮换在协议层是无感的——客户端只看到一个端点,背后是 N 个账号在自动接力。

需要区分的是,这里说的轮换是「账户轮换」,配置项是 fallbackStrategy。9router 还有一个独立的「代理池轮换」(rotateStrategy),那是给出站 HTTP 代理做轮换的,跟账户调度是两套机制,不要混淆。

请求构造:伪装成 Qoder CLI 的 agent 请求

选好 connection 后,进入请求构造阶段。Qoder executor(chunks/318.js 模块 84315,类 QoderExecutor)把 OpenAI 格式的请求体转换成 Qoder 的 agent payload。这个 payload 暴露了 9router 的伪装策略:

// chunks/318.js, QoderExecutor 构造的 payload(还原后)
const payload = {
  request_id: randomUUID(),
  request_set_id: hashOf(model, messages, tools, max_tokens),
  chat_record_id: hashOf(model, messages, tools, max_tokens),
  session_id: hashOf("qoder-session", userId, model),  // 本地哈希,非真实会话
  stream: true,
  chat_task: "FREE_INPUT",
  is_reply: true,
  source: 1,
  version: "3",
  session_type: "qodercli",      // 关键:伪装成 qoder 官方 CLI
  agent_id: "agent_common",       // 关键:走 agent_common 这条 agent
  task_id: "common",
  system: systemText,             // 从 messages 里抽出的 system 消息
  messages: nonSystemMessages,
  tools: body.tools || [],        // OpenAI schema 的工具原样上送
  parameters: { max_tokens },
  chat_context: {
    extra: { context: [], modelConfig: { key: modelKey, is_reasoning } },
    features: [], text: lastUserText,
  },
  business: { product: "cli", version: "1.0.0",
              type: "agent", stage: "start", id: randomUUID(),
              name: truncate(lastUserText, 30), begin_at: Date.now() },
  model_config: resolvedModelConfig,
};

两个字段决定了整个 harness 的形态:session_type: "qodercli" 让上游把请求当成来自 Qoder 官方 CLI,agent_id: "agent_common" 让请求路由到 Qoder 服务端的通用 agent。这意味着无论客户端是 Claude Code、Cursor 还是 opencode,经过 9router 后都被统一包装成「Qoder CLI 发起的 agent 请求」。客户端的 harness(Claude Code 的多步工具循环、opencode 的 agent 编排)和上游的 harness(Qoder 的 agent_common)在这里叠在了一起。

payload 里还有两个哈希值得注意。session_idsha256("qoder-session" + userId + model) 的前 16 位,对同一账户同一模型是恒定的;request_set_idchat_record_id 都是 sha256(model + messages + tools + max_tokens),随消息内容变化。这套设计让 Qoder 上游看到的是一堆不连贯的 record——9router 没有维护真正的服务端会话连续性,上下文完全靠客户端每次把完整 messages 重新发一遍。

请求体构造完后,还要过一道编码。Qoder executor 不是直接发 JSON,而是用一套自定义的 base64 变种编码(带字符替换的混淆)把 payload 包起来:

// chunks/318.js, body 编码(还原后)
function encodeQoderBody(buffer) {
  const base64 = buffer.toString("base64");
  const len = base64.length;
  const third = Math.floor(len / 3);
  // 三段交换:尾段 + 中段 + 头段
  const shuffled = base64.slice(len - third)
                  + base64.slice(third, len - third)
                  + base64.slice(0, third);
  // 再做一次字符替换映射
  const out = Buffer.alloc(len);
  for (let i = 0; i < len; i++) {
    const c = shuffled.charCodeAt(i);
    out[i] = (c < 128 && replaceMap[c] >= 0) ? replaceMap[c] : c;
  }
  return out.toString("latin1");
}

这套编码加上 COSY 签名,让 Qoder 的请求在网络上是一坨难以直接构造的二进制。9router 把这套全部封装在 executor 内部,对外只暴露标准的 OpenAI 请求接口——这是它作为协议网关的核心价值。

协议翻译:tool_use 的双向存活路径

代理一个 agent 类订阅,最大的协议挑战是工具调用(tool use)的透传。Claude Code 用的是 Anthropic 协议(/v1/messagescontent_block 事件流,tool_use content block),而 Qoder 上游返回的是自己的 SSE 格式。中间这层翻译是判断「代理后还能不能正常用工具」的关键。

最初容易误判的一点是:只看 Qoder executor 的响应处理函数,会以为 tool_use 被丢了。Qoder executor 对上游 SSE 的解析确实很粗暴——只抽取 body 字段:

// chunks/318.js, Qoder SSE 解析(还原后)
function transformQoderSSE(line, controller) {
  if (!line.startsWith("data:")) return;
  const json = JSON.parse(line.slice(5).trim());
  const status = json.statusCodeValue ?? 200;
  const body = typeof json.body === "string" ? json.body : "";

  if (status !== 200) {
    // 错误:包成一个带 [qoder error] 文本的 chunk
    controller.enqueue(textChunk(` [qoder error ${status}]`));
    return;
  }
  if (!body || body === "[DONE]") { controller.enqueue(doneFrame()); return; }
  // body 本身就是一段 OpenAI 格式的 chunk JSON,原样吐出
  const cleaned = body.replace(/\r?\n/g, "");
  controller.enqueue(`data: ${cleaned}\n\n`);
}

关键在最后一行:body 不是普通文本,而是 OpenAI 格式的 chat.completion.chunk JSON。Qoder 上游在 body 字段里返回的就是标准 OpenAI chunk,包括 delta.tool_calls。9router 只是把它原样透传,不做任何结构转换。

真正的翻译发生在两层转换器上(chunks/6805.js):

入向(Anthropic 请求 → OpenAI 请求),Claude Code 发来的 tool_use content block 被转成 OpenAI 的 tool_calls

// chunks/6805.js, Anthropic → OpenAI(还原后)
case "content_block_start":
  if (block.type === TOOL_USE) {
    const idx = state.toolCallIndex++;
    const toolCall = { index: idx, id: block.id, type: "function",
                       function: { name: block.name, arguments: "" } };
    state.toolCalls.set(blockIndex, toolCall);
    emit({ delta: { tool_calls: [toolCall] } });  // 转成 openai delta
  }
  break;
case "content_block_delta":
  if (delta.type === "input_json_delta") {
    const tc = state.toolCalls.get(blockIndex);
    tc.function.arguments += delta.partial_json;  // 累积工具参数
  }
  break;

出向(OpenAI 响应流 → Anthropic SSE 事件流),反过来把 OpenAI 的 tool_calls 重建为 Anthropic 的 tool_use content block,finish_reason 也做了语义映射:

// chunks/6805.js, OpenAI → Anthropic(还原后)
if (delta.tool_calls) {
  for (const tc of delta.tool_calls) {
    if (tc.id) {
      // 新工具调用:开一个 tool_use content block
      const blockIndex = state.nextBlockIndex++;
      state.toolCalls.set(tc.index, { id: tc.id, name: tc.function.name, blockIndex });
      emit({ type: "content_block_start", index: blockIndex,
             content_block: { type: "tool_use", id: tc.id, name: tc.function.name, input: {} } });
    }
    if (tc.function.arguments) {
      // 累积参数片段
      state.toolArgBuffers.set(tc.index, prev + tc.function.arguments);
    }
  }
}
if (chunk.finish_reason) {
  // 把累积的参数吐成 input_json_delta,再 close block
  for (const [idx, tc] of state.toolCalls) {
    emit({ type: "content_block_delta", index: tc.blockIndex,
           delta: { type: "input_json_delta", partial_json: argBuffer } });
    emit({ type: "content_block_stop", index: tc.blockIndex });
  }
  emit({ type: "message_delta",
         delta: { stop_reason: mapFinishReason(chunk.finish_reason) } });
  emit({ type: "message_stop" });
}

function mapFinishReason(reason) {
  switch (reason) {
    case "stop": default: return "end_turn";
    case "length": return "max_tokens";
    case "tool_calls": return "tool_use";  // openai tool_calls → anthropic tool_use
  }
}

把这两层翻译和 Qoder executor 的 body 透传串起来看,完整的工具调用链路是这样的:

Claude Code 发 Anthropic 请求 (tools + tool_use content block)
  → [入向翻译] 转成 OpenAI 请求 (tools + tool_calls)
  → QoderExecutor 构造 payload, tools 原样塞入
  → COSY 签名后发往 agent_common
  → 上游返回 SSE, body 字段是 OpenAI chunk (含 delta.tool_calls)
  → QoderExecutor 原样透传 body
  → [出向翻译] OpenAI chunk → Anthropic SSE (content_block_start tool_use + input_json_delta)
  → Claude Code 收到合法的 anthropic 事件流, 触发工具执行

链路是通的。 Claude Code 的工具循环、subagent(本质是起一个子会话再走一遍同样的链路)都能正常工作,前提是 Qoder 上游的 agent_common 确实在 body 里吐了结构化 tool_calls

但这条链路有一个细节损耗值得留意:Qoder executor 在透传 body 时做了 body.replace(/\r?\n/g, ""),把换行全删了。如果工具参数 JSON 里包含多行字符串(代码块、长文本),理论上会被压平。实际上 OpenAI chunk 的 arguments 字段通常是转义后的单行 JSON,影响有限,但这是一个潜在的脆弱点。

Harness 工程:服务端 agent 与客户端 agent 的对齐

回到最初的问题:经过 9router 代理后,原订阅的 harness 工程如何影响使用效果?答案不在协议层,而在 两个 harness 的叠加 上。

Qoder 的 harness 是服务端的 agent_common。从 payload 看,9router 强制把 session_type 设为 qodercliagent_id 设为 agent_commonbusiness.product 设为 cli。这意味着无论客户端是什么,上游都按「Qoder CLI 发起的 agent 请求」来处理。agent_common 这层 harness 会做自己的事情:规划、推理、决定输出格式、可能注入自己的 system 行为。客户端拿不到「裸 Qwen 3.7 Max 的原始补全」。

Claude Code 的 harness 是客户端的 agentic 循环。它按 Anthropic 协议发 tools,期待拿到 tool_use content block 去驱动「读文件→改→跑→反馈」的多步循环。opencode 的 harness 类似,但可能用 OpenAI 协议或自己的工具协议。

两层 harness 叠加后,影响是结构性的、双向的:

上行方向,客户端按 OpenAI/Anthropic schema 传进来的工具定义,9router 原样塞进 payload 的 tools 字段上送给 Qoder。但 Qoder 原生 agent 有自己的工具体系(为 qoder CLI/IDE 设计的 file、bash 那套),跟 OpenAI 的 function.input_schema 不是一回事。agent_common 多半会按自己的方式理解或忽略这些工具定义。客户端的工具 schema 和上游的工具 schema 对不齐,是协议层无法解决的语义鸿沟。

下行方向,如果 agent_common 决定「调工具」,它只能在服务端调(它没有访问客户端文件系统的能力),返回的还是文本或结构化 chunk。客户端的工具循环收到 tool_use 后去本地执行,再把结果喂回去——这个回路是通的,但 agent_common 的服务端工具调用和客户端的本地工具调用是两套独立的东西,可能产生重复或冲突的执行。

会话方向,9router 的 session_id 是本地哈希的(sha256("qoder-session", userId, model)),不是 Qoder 真正的会话 ID。客户端每轮都把完整历史重新发一遍(无状态),Qoder 服务端那点「会话记忆」形同虚设。这意味着不能指望 Qoder 服务端的上下文连续性,所有上下文管理责任都在客户端。

模型选择方向,Qoder 在 9router 静态配置里只暴露 qmodel_latest 一个模型,但实际可用模型是动态拉取的。app/api/providers/[id]/models/route.js 里有 Qoder 的 customResolver,登录后调 /algo/api/v2/model/list(同样带 COSY 签名)拿到该账户可见的所有模型:

// chunks/203.js, 模型列表解析(还原后)
const json = await res.json();
if (!Array.isArray(json.chat)) return null;
for (const m of json.chat) {
  if (!m.key || m.enable === false) continue;
  models.push({
    id: m.key,
    name: m.display_name || m.key,
    contextLength: Number(m.max_input_tokens) || 131072,
    isVL: !!m.is_vl,
    isReasoning: !!m.is_reasoning,
    maxOutputTokens: Number(m.max_output_tokens) || 0,
  });
}

结果缓存 1 小时。不同账户(免费/Pro/企业)看到的模型列表可能不同,取决于上游 /model/list 给什么。Dashboard 的 Qoder → Models 页能看到该账户实际可用的全部模型,qmodel_latest 只是静态兜底。如果上游有但 /model/list 没返回的模型,还能在 provider 详情页手动加。

扩展点:Custom Provider Node

除了内置的 Qoder、Claude、Codex 等 OAuth provider,9router 还支持 openai-compatibleanthropic-compatible 两种自定义节点(src/cli/menus/providers.jsCUSTOM_NODE_TYPES)。这给了接入第三方反代站的余地:

Dashboard → Providers → Custom Providers → Add
  Type:    openai-compatible
  Name:    反代站
  Prefix:  gw        # 模型 ID 就是 gw/xxx
  Base URL: https://反代站/v1
  API Type: chat 或 responses

加完后给这个节点配 API Key 连接,反代站反代的所有 Qoder 模型都能用 gw/<模型名> 调用,并且同样参与多账户轮换、combo 组合、fallback。这是绕开 Qoder 官方 harness 的另一条路——如果反代站直接对接的是 Qoder 的裸模型端点而非 agent_common,那 harness 影响会小很多。但代价是失去 Qoder 官方 agent 的能力,也享受不到 COSY 签名那套防重放保护。

把代理机制和 harness 影响合起来看

9router 作为一个协议网关,对 Qoder 的接入在协议层是完整的:device flow 鉴权、COSY 签名、SSE 流式、双向 Anthropic↔OpenAI 翻译、多账户 sticky 轮换、动态模型拉取,每一环都有对应实现。客户端只要支持 OpenAI 或 Anthropic 协议,就能透明地消费 Qoder 订阅额度,包括工具调用和 subagent。

但「协议层完整」不等于「体验无损」。真正的损耗发生在 harness 叠加上:

  • Qoder 的 agent_common 是强制的,9router 没有绕过它的开关。所有请求都被包装成「Qoder CLI 发起的 agent 请求」,上游会做服务端规划/推理/工具编排,客户端拿不到裸模型补全。
  • 工具 schema 不对齐,客户端按 OpenAI/Anthropic schema 传的工具,上游 agent_common 按自己的工具体系理解,可能忽略或误读。
  • 会话是假会话session_id 本地哈希,无服务端连续性,上下文全靠客户端无状态重发。
  • auto-ping 不覆盖 Qoder,余额不会自动刷新,多账户监控要自己写脚本。
  • token 不可刷新,30 天到期后要手动重走 device flow。

这些不是 9router 的实现缺陷,而是 Qoder 这类带强服务端 harness 的订阅产品被代理时的固有特性。9router 在协议层做了能做的一切——它把 COSY 签名、base64 编码、SSE 解析这些黑盒全部封掉,对外只暴露干净的 OpenAI/Anthropic 接口。但封不掉的是 agent_common 这层服务端 harness,因为它就是 Qoder 产品的一部分,不是协议层的细节。

对一个想用 Qoder 订阅跑 Claude Code 的人,结论是:能用,工具调用和 subagent 都通,但要接受模型行为会被 agent_common 这层 harness 调制。如果追求「裸模型」体验,要么去找直连裸端点的反代站(用 custom node 接入),要么接受 harness 叠加带来的行为差异。代理网关能抹平协议,抹不平 harness。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐