9router代理Qoder的机制分析:从鉴权签名到harness工程的协议穿透
把一个订阅制的 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,核心是 initiateDeviceFlow 和 pollDeviceToken 两个方法。
整个流程从本地生成三组随机值开始:
// 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),又是后续轮询的凭据。pollDeviceToken 用 nonce + 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/userinfo 取 userId、organizationId,把整套凭据作为一条 connection 存进本地 SQLite(Windows 下位于 %APPDATA%/9router/db/data.sqlite)。其中 userId 和 machineId 是后续发请求的关键,accessToken 约 30 天后过期。
这里有一个工程上很关键的细节:Qoder 在配置里被标了 refreshable: false。refreshAndUpdateCredentials 函数会调用 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-Machineid 和 Cosy-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,
};
}
返回结构里 user 和 organization 两级配额分得很清楚——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.providers(chunks/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: true 加 retryAfter,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_id 是 sha256("qoder-session" + userId + model) 的前 16 位,对同一账户同一模型是恒定的;request_set_id 和 chat_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/messages,content_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 设为 qodercli、agent_id 设为 agent_common、business.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-compatible 和 anthropic-compatible 两种自定义节点(src/cli/menus/providers.js 的 CUSTOM_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。
更多推荐



所有评论(0)