waygate-qoder 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhanghaojie513
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # waygate-qoder
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <div align="center">
4
+
5
+ **途关 · Qoder CN 反代**
6
+
7
+ 把 Qoder CN 客户端的订阅,变成你自己的本地 API
8
+
9
+ ![Node](https://img.shields.io/badge/Node-%E2%89%A520-3C873A?style=flat-square&logo=node.js&logoColor=white)
10
+ ![Platform](https://img.shields.io/badge/Platform-Windows-0078D4?style=flat-square&logo=windows&logoColor=white)
11
+ ![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)
12
+
13
+ [快速开始](#快速开始) · [客户端配置](#客户端怎么填) · [工作原理](#工作原理) · [常见问题](#常见问题) · [相关项目](#相关项目)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ **读取本机 Qoder 客户端的登录态,在本机起一个 Anthropic / OpenAI 兼容的聊天端点** ——
20
+ Cursor、Claude Code、Cherry Studio、任何能改 base_url 的工具,都能直接用你的订阅额度。
21
+
22
+ - **零配置接入** —— 一条命令起服务,打印出「代理地址 + 密钥 + 模型 ID」,复制粘贴即可
23
+ - **两个协议都认** —— `POST /v1/chat/completions`(OpenAI 兼容)与 `POST /v1/messages`(Anthropic 兼容)
24
+ - **只在本机转** —— 读本机客户端文件、写 `~/.waygate/`、本机转发;不向任何第三方上传数据
25
+
26
+ ## 快速开始
27
+
28
+ ```bash
29
+ # 全局安装(之后命令就是 qoc)
30
+ npm i -g waygate-qoder
31
+
32
+ # 启动
33
+ qoc
34
+ ```
35
+
36
+ ```
37
+ 途关 · Qoder CN 反代 v0.1.0
38
+
39
+ 凭证 ✓ 你的账号(uid 01a0d156…)
40
+ 上游 https://gateway.qoder.com.cn (COSY 签名协议)
41
+ 代理 http://127.0.0.1:8789
42
+ 密钥 sk-qoc-3f8a…c21d
43
+
44
+ 模型 ID(填进你的客户端):
45
+ qmodel_38max Qwen3.8-Max
46
+ qfmodel Qwen3.8-Flash
47
+ qmodel Qwen3.7-Plus
48
+ …(共 14 个)
49
+
50
+ 把上面的「代理地址 + 密钥」填进 Cursor / Claude Code / Cherry Studio 等客户端即可。
51
+ 按 Ctrl+C 停止。
52
+ ```
53
+
54
+ > 不想全局装:`git clone` 后 `npm install && npm run build && node dist/index.js`。
55
+
56
+ **要求**:Node.js ≥ 20;本机装有 Qoder CN 桌面客户端**且已登录**。
57
+
58
+ ## 命令
59
+
60
+ ```bash
61
+ qoc # 启动反代(同 qoc serve),打印模型 ID 与代理地址
62
+ qoc models # 只列出模型 ID(不起服务)
63
+ qoc doctor # 自检:端口 / 凭证 / 模型清单 / 上游连通
64
+ qoc login # 重新读取客户端登录态(令牌过期时用)
65
+ ```
66
+
67
+ | 参数 | 说明 |
68
+ | --- | --- |
69
+ | `--port <n>` | 监听端口(默认 8789) |
70
+ | `--host <addr>` | 监听地址(默认 `127.0.0.1`) |
71
+ | `--key <sk-…>` | 指定入站密钥(默认自动生成并缓存) |
72
+ | `--json` | 机器可读输出 |
73
+ | `--verbose` | 打印每个请求的路由日志 |
74
+
75
+ ## 客户端怎么填
76
+
77
+ **Cursor / Cherry Studio / OpenAI SDK**
78
+
79
+ - Base URL:`http://127.0.0.1:8789/v1`
80
+ - API Key:启动时打印的 `sk-qoc-…`
81
+ - 模型:模型 ID(如 `qfmodel`)
82
+
83
+ **Claude Code**
84
+
85
+ ```bash
86
+ ANTHROPIC_BASE_URL=http://127.0.0.1:8789
87
+ ANTHROPIC_API_KEY=sk-qoc-…
88
+ ```
89
+
90
+ 暴露的端点:`POST /v1/chat/completions` · `POST /v1/messages` · `GET /v1/models` · `GET /healthz`
91
+
92
+ 支持流式(SSE)与非流式(`stream: false` 时自动聚合为单个 JSON)两种响应模式。
93
+
94
+ ## 工作原理
95
+
96
+ ```mermaid
97
+ flowchart LR
98
+ C[你的客户端<br/>Cursor / Claude Code]
99
+ Q[qoc<br/>本机 8789]
100
+ G[Qoder 网关]
101
+
102
+ C -->|OpenAI / Anthropic 协议| Q
103
+ Q -->|COSY 签名协议| G
104
+
105
+ subgraph local [本机]
106
+ Q
107
+ F[~/.waygate/qoc/credential.json]
108
+ end
109
+ F -.->|qoc login 写入| Q
110
+
111
+ style Q fill:#EDE9FE,stroke:#8B5CF6,color:#1F2328
112
+ style F fill:#FFFFFF,stroke:#D0D7DE,color:#1F2328
113
+ style C fill:#FFFFFF,stroke:#D0D7DE,color:#1F2328
114
+ style G fill:#FFFFFF,stroke:#D0D7DE,color:#1F2328
115
+ style local fill:#F6F8FA,stroke:#D0D7DE
116
+ ```
117
+
118
+ - **凭证**:Qoder 客户端把登录态存在 Chromium 的 `auth.v1.dat`(os_crypt **v10**,密钥由 Windows **DPAPI** 保护)。`qoc login` 用本机 DPAPI 解出令牌,写到 `~/.waygate/qoc/credential.json`(0600)。
119
+ - **协议**:Qoder 网关用一套自研签名(**COSY** bearer + 自定义 base64 编码)与 SSE 帧格式;本工具把它翻译成标准 OpenAI SSE 形状下发。
120
+ - **模型清单**:真拉网关 `/model/list`(不是内置假清单),模型 `id` 即客户端请求要填的 key。
121
+
122
+ > [!NOTE]
123
+ > **遇到大上下文时偶发「HTTP 200 但零字节」** —— 这是 Qoder 网关的已知现象(实测 2.7 万 token 时会先空转近 30 秒才断流)。本工具会自动重发一次(最多 1 次),客户端只会感到响应稍慢,不会收到半截内容。
124
+
125
+ ## 常见问题
126
+
127
+ **要装 Qoder 客户端吗?** 要。本工具不提供账号,只是把你**已有**的客户端登录态转成本地 API。
128
+
129
+ **令牌过期了怎么办?** 在 Qoder 客户端重新登录,然后跑一次 `qoc login`;`qoc doctor` 会告诉你凭证是否还有效。
130
+
131
+ **会不会把我的令牌上传?** 不会。全程只在本机读写:读客户端文件 → 写 `~/.waygate/` → 本机转发。代码开源可查。
132
+
133
+ **端口被占用?** `qoc --port 9790`,或 `qoc doctor` 看是哪一项没过。
134
+
135
+ **支持 macOS / Linux 吗?** 暂只支持 Windows —— 客户端用 **DPAPI**(Windows 专有)保护密钥。
136
+
137
+ > [!WARNING]
138
+ > **想给局域网其他设备用?** 加 `--host 0.0.0.0` —— 请想清楚:那等于把你订阅的额度开放给能访问该端口的所有人。
139
+
140
+ ## 免责声明
141
+
142
+ 仅供**个人学习与研究**使用,请遵守 Qoder 的服务条款。上游接口随时可能变更,届时本工具可能失效(`qoc doctor` 是排查入口)。作者不对使用后果负责。
143
+
144
+ ## 相关项目
145
+
146
+ - **waygate-mmcode** —— 同款工具,对应 MiniMax Code(*即将发布*)
147
+ - **[waygate-core](https://github.com/zhanghaojie513/waygate-core)** —— 两个工具共用的骨架(`npm i waygate-core`)
148
+ - **[relay-station](https://github.com/zhanghaojie513/relay-station)** —— 同一作者的完整多提供商反代(含控制台、用量统计)
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Qoder CN 适配器:把「本机客户端登录态」变成「本地 Anthropic/OpenAI 兼容反代」。
3
+ *
4
+ * 职责:凭证(读客户端登录态)→ 协议(COSY 签名 + 翻译 + 流解码)→ 交给 core 的 HTTP 服务。
5
+ * 上游知识全在本文件与 protocol/ 里,core 不含任何 Qoder 细节。
6
+ */
7
+ import { type Adapter } from 'waygate-core';
8
+ import { type QoderIdentity } from './credential.js';
9
+ import { CredentialError } from './errors.js';
10
+ /** 凭证落盘位置(`~/.waygate/qoc/credential.json`)—— 首次 login 后复用,避免每次读客户端文件。 */
11
+ export declare function credentialPath(): string;
12
+ export declare function resolveIdentity(): QoderIdentity;
13
+ /** 落盘身份材料(0600)并更新内存缓存。 */
14
+ export declare function saveIdentity(identity: QoderIdentity): string;
15
+ export declare function createQoderAdapter(opts?: {
16
+ baseUrl?: string;
17
+ chatPath?: string;
18
+ }): Adapter;
19
+ export { CredentialError };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Qoder CN 适配器:把「本机客户端登录态」变成「本地 Anthropic/OpenAI 兼容反代」。
3
+ *
4
+ * 职责:凭证(读客户端登录态)→ 协议(COSY 签名 + 翻译 + 流解码)→ 交给 core 的 HTTP 服务。
5
+ * 上游知识全在本文件与 protocol/ 里,core 不含任何 Qoder 细节。
6
+ */
7
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { commandDir } from 'waygate-core';
10
+ import { readQoderIdentity } from './credential.js';
11
+ import { CredentialError, UpstreamError } from './errors.js';
12
+ import { loadModels } from './models.js';
13
+ import { buildCosyBearer, cosyChatHeaders, DEFAULT_QODER_BASE_URL, DEFAULT_QODER_CHAT_PATH, newCosySession, qEncode, } from './protocol/cosy.js';
14
+ import { qoderToOpenAiSse } from './protocol/stream-decode.js';
15
+ import { toQoderRequest } from './protocol/translate.js';
16
+ /** 凭证落盘位置(`~/.waygate/qoc/credential.json`)—— 首次 login 后复用,避免每次读客户端文件。 */
17
+ export function credentialPath() {
18
+ return join(commandDir('qoc'), 'credential.json');
19
+ }
20
+ /**
21
+ * 取身份材料:优先本地缓存副本(login 落盘),否则现读客户端登录态。
22
+ * [复核修正] 加内存缓存 —— 此前每个聊天请求都调本函数;凭证文件不存在时
23
+ * 每次都会走 DPAPI 解密(起 PowerShell 进程),代价极高。
24
+ */
25
+ let identityCache = null;
26
+ export function resolveIdentity() {
27
+ if (identityCache)
28
+ return identityCache;
29
+ const cached = credentialPath();
30
+ if (existsSync(cached)) {
31
+ const parsed = JSON.parse(readFileSync(cached, 'utf8'));
32
+ if (parsed.token && parsed.uid) {
33
+ identityCache = parsed;
34
+ return parsed;
35
+ }
36
+ }
37
+ identityCache = readQoderIdentity();
38
+ return identityCache;
39
+ }
40
+ /** 落盘身份材料(0600)并更新内存缓存。 */
41
+ export function saveIdentity(identity) {
42
+ const path = credentialPath();
43
+ writeFileSync(path, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
44
+ try {
45
+ chmodSync(path, 0o600);
46
+ }
47
+ catch {
48
+ /* Windows 语义有限 */
49
+ }
50
+ identityCache = identity;
51
+ return path;
52
+ }
53
+ function mask(s) {
54
+ return s.length > 10 ? `${s.slice(0, 6)}…(${s.length}B)` : '***';
55
+ }
56
+ export function createQoderAdapter(opts = {}) {
57
+ const baseUrl = opts.baseUrl ?? DEFAULT_QODER_BASE_URL;
58
+ const chatPath = opts.chatPath ?? DEFAULT_QODER_CHAT_PATH;
59
+ return {
60
+ command: 'qoc',
61
+ displayName: 'Qoder CN',
62
+ defaultPort: 8789,
63
+ upstreamLabel: `${baseUrl} (COSY 签名协议)`,
64
+ async credentialStatus() {
65
+ try {
66
+ const id = resolveIdentity();
67
+ return { ok: true, detail: `${id.name}(uid ${id.uid.slice(0, 8)}…,令牌 ${mask(id.token)})`, source: existsSync(credentialPath()) ? credentialPath() : undefined };
68
+ }
69
+ catch (err) {
70
+ return { ok: false, detail: err.message };
71
+ }
72
+ },
73
+ async login() {
74
+ const id = readQoderIdentity();
75
+ const path = saveIdentity(id);
76
+ process.stdout.write(`已读取客户端登录态:${id.name}(uid ${id.uid})\n已保存到 ${path}\n`);
77
+ },
78
+ async listModels() {
79
+ return loadModels(baseUrl, resolveIdentity());
80
+ },
81
+ async doctorChecks() {
82
+ /* [复核修正] 此前 .catch(()=>null) 吞掉了所有网络错误 → 「上游连通」恒报 ✓。
83
+ fetch 只在网络不可达(DNS/超时/拒连)时抛错;HTTP 404/502 不抛(反而证明可达)。 */
84
+ try {
85
+ await fetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(8000) });
86
+ return [{ name: '上游连通', ok: true, detail: `${baseUrl} 可达` }];
87
+ }
88
+ catch (err) {
89
+ return [{ name: '上游连通', ok: false, detail: err.message }];
90
+ }
91
+ },
92
+ async chat(body, signal) {
93
+ const identity = resolveIdentity();
94
+ /* [零内容重试] Qoder 网关在大上下文时会「HTTP 200 但零字节断流」(实测 2026-10-12:
95
+ 2.7 万 token 时 27.8s 零字节;同日同模型重发 4.9s 成功)。客户端此时什么都还没收到,
96
+ 重发完全无感 —— 上限 1 次,避免上游真故障时把延迟翻倍。
97
+ 必须**在把流交给客户端前**探首块:一旦 core 提交了 200 响应头就没法重发了。 */
98
+ const MAX_RETRY = 1;
99
+ let lastErr = null;
100
+ for (let attempt = 0; attempt <= MAX_RETRY; attempt += 1) {
101
+ if (attempt > 0)
102
+ await new Promise((r) => setTimeout(r, 1000));
103
+ const session = newCosySession(identity);
104
+ const qoderBody = toQoderRequest(body, body.model, identity.userType);
105
+ /* 请求体必须走网关的自定义 base64(Encode=1)—— 直接发 JSON 会被上游
106
+ CustomBase64Util.decode0 拒绝(实测:event:error + Java 栈)。 */
107
+ const encoded = qEncode(JSON.stringify(qoderBody));
108
+ const pathOnly = chatPath.split('?')[0];
109
+ const date = String(Math.floor(Date.now() / 1000));
110
+ const bearer = buildCosyBearer(session, date, encoded, pathOnly);
111
+ const res = await fetch(`${baseUrl}${chatPath}`, {
112
+ method: 'POST',
113
+ headers: cosyChatHeaders(session, identity, bearer, date, 'text/event-stream'),
114
+ body: encoded,
115
+ signal,
116
+ });
117
+ if (!res.ok) {
118
+ const text = await res.text().catch(() => '');
119
+ if (res.status === 401 || res.status === 403) {
120
+ throw new UpstreamError(`上游鉴权失败(HTTP ${res.status})—— 令牌可能已过期:在 Qoder 客户端重新登录后跑一次 qoc login`);
121
+ }
122
+ throw new UpstreamError(`上游 HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}`);
123
+ }
124
+ if (!res.body) {
125
+ lastErr = new UpstreamError('上游返回 200 但响应体为空');
126
+ continue;
127
+ }
128
+ /* 探首块:拿到就把它塞回新流(不能丢首帧内容);拿不到 → 零内容断流 → 重试。 */
129
+ const reader = res.body.getReader();
130
+ let first;
131
+ try {
132
+ first = await reader.read();
133
+ }
134
+ catch (err) {
135
+ lastErr = err;
136
+ if (attempt < MAX_RETRY)
137
+ continue;
138
+ throw new UpstreamError(`读取上游流失败:${err.message}`);
139
+ }
140
+ if (first.done || !first.value || first.value.length === 0) {
141
+ lastErr = new UpstreamError('零内容断流(耗时较长或上下文过大)');
142
+ if (attempt < MAX_RETRY)
143
+ continue;
144
+ throw lastErr;
145
+ }
146
+ const withFirst = new ReadableStream({
147
+ start(controller) {
148
+ controller.enqueue(first.value);
149
+ },
150
+ async pull(controller) {
151
+ const { done, value } = await reader.read();
152
+ if (done) {
153
+ controller.close();
154
+ return;
155
+ }
156
+ controller.enqueue(value);
157
+ },
158
+ cancel(reason) {
159
+ void reader.cancel(reason);
160
+ },
161
+ });
162
+ /* 网关 SSE → OpenAI SSE(core 只负责把流吐给客户端)。 */
163
+ return { stream: withFirst.pipeThrough(qoderToOpenAiSse(body.model)), status: 200 };
164
+ }
165
+ throw lastErr ?? new UpstreamError('上游调用失败');
166
+ },
167
+ };
168
+ }
169
+ export { CredentialError };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Qoder CN 客户端登录态 → 凭证明文。
3
+ *
4
+ * 客户端把登录态放在 Chromium 的 `auth.v1.dat`(os_crypt v10:AES-256-GCM,密钥由
5
+ * Windows DPAPI 保护)。解密链(与社区实现同源、本项目生产验证过):
6
+ * Local State 的 `os_crypt.encrypted_key`(base64,去 5 字节前缀 "DPAPI")
7
+ * → PowerShell 调 DPAPI Unprotect(CurrentUser)→ 32 字节 AES key
8
+ * → 解 auth.v1.dat:[3B "v10"][12B nonce][ct…][16B tag]
9
+ *
10
+ * 只读本机文件、只在本机解密;令牌仅写到本机 `~/.waygate/qoc/`,不上传任何地方。
11
+ */
12
+ /** Qoder 客户端身份材料(`providers/<id>.key` 的同形 JSON)。 */
13
+ export interface QoderIdentity {
14
+ /** 访问令牌(`dt-…`)。 */
15
+ token: string;
16
+ /** 刷新令牌(`drt-…`)。 */
17
+ refreshToken: string;
18
+ uid: string;
19
+ name: string;
20
+ userType: string;
21
+ }
22
+ /** 客户端数据目录(可用 QODER_APP_DIR 覆盖,便于便携部署/异机取证)。 */
23
+ export declare function qoderAppDir(): string;
24
+ /** 平台是否支持(DPAPI 是 Windows 专有)。 */
25
+ export declare function isSupportedPlatform(): boolean;
26
+ /**
27
+ * 从本机客户端登录态解出身份材料。
28
+ * 失败一律抛 `CredentialError`(人话描述,CLI 直接展示,不抛栈)。
29
+ */
30
+ export declare function readQoderIdentity(): QoderIdentity;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Qoder CN 客户端登录态 → 凭证明文。
3
+ *
4
+ * 客户端把登录态放在 Chromium 的 `auth.v1.dat`(os_crypt v10:AES-256-GCM,密钥由
5
+ * Windows DPAPI 保护)。解密链(与社区实现同源、本项目生产验证过):
6
+ * Local State 的 `os_crypt.encrypted_key`(base64,去 5 字节前缀 "DPAPI")
7
+ * → PowerShell 调 DPAPI Unprotect(CurrentUser)→ 32 字节 AES key
8
+ * → 解 auth.v1.dat:[3B "v10"][12B nonce][ct…][16B tag]
9
+ *
10
+ * 只读本机文件、只在本机解密;令牌仅写到本机 `~/.waygate/qoc/`,不上传任何地方。
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createDecipheriv } from 'node:crypto';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { CredentialError } from './errors.js';
18
+ /** 客户端数据目录(可用 QODER_APP_DIR 覆盖,便于便携部署/异机取证)。 */
19
+ export function qoderAppDir() {
20
+ const override = (process.env.QODER_APP_DIR ?? '').trim();
21
+ if (override)
22
+ return override;
23
+ const roaming = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
24
+ return join(roaming, 'com.qodercn.app.stable');
25
+ }
26
+ /** 平台是否支持(DPAPI 是 Windows 专有)。 */
27
+ export function isSupportedPlatform() {
28
+ return process.platform === 'win32';
29
+ }
30
+ /** 用 DPAPI 解出 os_crypt 的 AES key(Windows;走 PowerShell 免原生依赖)。 */
31
+ function osCryptKey(appDir) {
32
+ const localStatePath = join(appDir, 'Local State');
33
+ if (!existsSync(localStatePath)) {
34
+ throw new CredentialError(`找不到客户端文件:${localStatePath}(客户端未安装或未运行过?可用 QODER_APP_DIR 指定目录)`);
35
+ }
36
+ const ls = JSON.parse(readFileSync(localStatePath, 'utf8'));
37
+ const b64 = ls.os_crypt?.encrypted_key;
38
+ if (!b64)
39
+ throw new CredentialError('Local State 里没有 os_crypt.encrypted_key —— 客户端版本可能变了');
40
+ const encrypted = Buffer.from(b64, 'base64');
41
+ const hex = execFileSync('powershell', [
42
+ '-NoProfile',
43
+ '-Command',
44
+ `$enc = [Convert]::FromBase64String('${encrypted.toString('base64')}'); $enc = $enc[5..($enc.Length-1)]; Add-Type -AssemblyName System.Security; ([Security.Cryptography.ProtectedData]::Unprotect($enc, $null, 'CurrentUser') | ForEach-Object { $_.ToString('x2') }) -join ''`,
45
+ ], { encoding: 'utf8' }).trim();
46
+ if (!hex)
47
+ throw new CredentialError('DPAPI 解密返回空(当前用户与写入该文件的用户不一致?)');
48
+ return Buffer.from(hex, 'hex');
49
+ }
50
+ /** 解 `auth.v1.dat`(v10 形态)。 */
51
+ function decryptV10(blob, key) {
52
+ if (blob.subarray(0, 3).toString() !== 'v10') {
53
+ throw new CredentialError('auth.v1.dat 不是 v10 格式(客户端加密方案变了?)');
54
+ }
55
+ const nonce = blob.subarray(3, 15);
56
+ const tag = blob.subarray(blob.length - 16);
57
+ const ct = blob.subarray(15, blob.length - 16);
58
+ const d = createDecipheriv('aes-256-gcm', key, nonce);
59
+ d.setAuthTag(tag);
60
+ return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
61
+ }
62
+ /**
63
+ * 从本机客户端登录态解出身份材料。
64
+ * 失败一律抛 `CredentialError`(人话描述,CLI 直接展示,不抛栈)。
65
+ */
66
+ export function readQoderIdentity() {
67
+ if (!isSupportedPlatform()) {
68
+ throw new CredentialError(`当前平台(${process.platform})暂不支持自动读取 Qoder 登录态(DPAPI 为 Windows 专有);请手动导出 identity JSON 后放入 ~/.waygate/qoc/credential.json`);
69
+ }
70
+ const appDir = qoderAppDir();
71
+ const authFile = join(appDir, 'auth.v1.dat');
72
+ if (!existsSync(authFile)) {
73
+ throw new CredentialError(`找不到登录态文件 ${authFile} —— 请先在 Qoder 客户端登录;或用 QODER_APP_DIR 指定客户端数据目录`);
74
+ }
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(decryptV10(readFileSync(authFile), osCryptKey(appDir)));
78
+ }
79
+ catch (err) {
80
+ if (err instanceof CredentialError)
81
+ throw err;
82
+ throw new CredentialError(`解密登录态失败:${err.message}(客户端未登录,或文件被其他程序占用?)`);
83
+ }
84
+ const user = (parsed.user ?? {});
85
+ const identity = {
86
+ token: String(parsed.token ?? ''),
87
+ refreshToken: String(parsed.refreshToken ?? ''),
88
+ uid: String(user.id ?? ''),
89
+ name: String(user.name ?? 'qoder-user'),
90
+ userType: 'personal_standard',
91
+ };
92
+ for (const [k, v] of Object.entries(identity)) {
93
+ if (!v && k !== 'name')
94
+ throw new CredentialError(`登录态缺字段 ${k} —— 客户端未登录?请在客户端登录后重试`);
95
+ }
96
+ return identity;
97
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 上游错误(core 会把它兜成 502 + OpenAI 形状错误体给客户端)。
3
+ */
4
+ export declare class UpstreamError extends Error {
5
+ readonly name = "UpstreamError";
6
+ constructor(message: string);
7
+ }
8
+ /** 凭证类错误(启动/doctor 用:提示用户跑 login 而不是抛栈)。 */
9
+ export declare class CredentialError extends Error {
10
+ readonly name = "CredentialError";
11
+ constructor(message: string);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 上游错误(core 会把它兜成 502 + OpenAI 形状错误体给客户端)。
3
+ */
4
+ export class UpstreamError extends Error {
5
+ name = 'UpstreamError';
6
+ constructor(message) {
7
+ super(message);
8
+ }
9
+ }
10
+ /** 凭证类错误(启动/doctor 用:提示用户跑 login 而不是抛栈)。 */
11
+ export class CredentialError extends Error {
12
+ name = 'CredentialError';
13
+ constructor(message) {
14
+ super(message);
15
+ }
16
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * waygate-qoder —— Qoder CN 本地反代(命令 `qoc`)。
4
+ *
5
+ * 读取本机 Qoder 客户端的登录态,在本机提供一个 Anthropic/OpenAI 兼容的聊天端点,
6
+ * 供 Cursor / Claude Code / Cherry Studio 等客户端使用。只在本机读写与转发,不上传任何数据。
7
+ */
8
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * waygate-qoder —— Qoder CN 本地反代(命令 `qoc`)。
4
+ *
5
+ * 读取本机 Qoder 客户端的登录态,在本机提供一个 Anthropic/OpenAI 兼容的聊天端点,
6
+ * 供 Cursor / Claude Code / Cherry Studio 等客户端使用。只在本机读写与转发,不上传任何数据。
7
+ */
8
+ import { runCli } from 'waygate-core';
9
+ import { createQoderAdapter } from './adapter.js';
10
+ const VERSION = '0.1.0';
11
+ const code = await runCli(createQoderAdapter(), { version: VERSION });
12
+ process.exit(code);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 模型清单:拉 Qoder 网关的 `/algo/api/v2/model/list`(真接口,非本地种子)。
3
+ *
4
+ * 关键设计(沿用 relay 的取舍):网关给的是「显示名 ↔ 内部 key」映射,而**客户端请求里
5
+ * 传的是内部 key**。所以模型 id = 内部 key(路由键),displayName = 显示名给人看。
6
+ */
7
+ import { type CosySession } from './protocol/cosy.js';
8
+ import type { QoderIdentity } from './credential.js';
9
+ import type { ModelEntry } from 'waygate-core';
10
+ export declare const QODER_MODEL_LIST_PATH = "/algo/api/v2/model/list?Encode=1";
11
+ /** 从网关响应里抽模型(取 chat 场景;enable=false 的跳过)。 */
12
+ export declare function parseQoderModels(payload: unknown): ModelEntry[];
13
+ /** 拉取模型清单(真打网关)。 */
14
+ export declare function fetchQoderModels(opts: {
15
+ baseUrl: string;
16
+ identity: QoderIdentity;
17
+ session: CosySession;
18
+ timeoutMs?: number;
19
+ }): Promise<ModelEntry[]>;
20
+ /** 便捷:从身份材料建会话 + 拉模型(适配器与 doctor 共用)。 */
21
+ export declare function loadModels(baseUrl: string, identity: QoderIdentity): Promise<ModelEntry[]>;
package/dist/models.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 模型清单:拉 Qoder 网关的 `/algo/api/v2/model/list`(真接口,非本地种子)。
3
+ *
4
+ * 关键设计(沿用 relay 的取舍):网关给的是「显示名 ↔ 内部 key」映射,而**客户端请求里
5
+ * 传的是内部 key**。所以模型 id = 内部 key(路由键),displayName = 显示名给人看。
6
+ */
7
+ import { buildCosyBearer, cosyChatHeaders, newCosySession } from './protocol/cosy.js';
8
+ import { UpstreamError } from './errors.js';
9
+ export const QODER_MODEL_LIST_PATH = '/algo/api/v2/model/list?Encode=1';
10
+ /** 从网关响应里抽模型(取 chat 场景;enable=false 的跳过)。 */
11
+ export function parseQoderModels(payload) {
12
+ if (!payload || typeof payload !== 'object')
13
+ return [];
14
+ const scenes = payload;
15
+ /* 形状:{ sceneName: [ {key, display_name, enable, …} ] };取 chat,缺失时取第一个非空场景。 */
16
+ const raw = Array.isArray(scenes.chat)
17
+ ? scenes.chat
18
+ : Object.values(scenes).find((v) => Array.isArray(v) && v.length > 0) ?? [];
19
+ const out = [];
20
+ for (const item of raw) {
21
+ if (!item || typeof item !== 'object')
22
+ continue;
23
+ const rec = item;
24
+ if (rec.enable === false)
25
+ continue;
26
+ const id = typeof rec.key === 'string' ? rec.key : null;
27
+ if (!id)
28
+ continue;
29
+ const displayName = typeof rec.display_name === 'string' && rec.display_name ? rec.display_name : id;
30
+ out.push({ id, displayName });
31
+ }
32
+ return out;
33
+ }
34
+ /** 拉取模型清单(真打网关)。 */
35
+ export async function fetchQoderModels(opts) {
36
+ const path = QODER_MODEL_LIST_PATH;
37
+ const pathOnly = path.split('?')[0];
38
+ const date = String(Math.floor(Date.now() / 1000));
39
+ const bearer = buildCosyBearer(opts.session, date, '', pathOnly);
40
+ const headers = cosyChatHeaders(opts.session, opts.identity, bearer, date, 'application/json');
41
+ const res = await fetch(`${opts.baseUrl}${path}`, {
42
+ method: 'GET',
43
+ headers,
44
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
45
+ });
46
+ if (!res.ok)
47
+ throw new UpstreamError(`模型清单 HTTP ${res.status}(凭证过期?在客户端重新登录后跑一次 qoc login)`);
48
+ const text = await res.text();
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ throw new UpstreamError('模型清单响应不是 JSON(网关协议变了?)');
55
+ }
56
+ const models = parseQoderModels(parsed);
57
+ if (models.length === 0)
58
+ throw new UpstreamError('模型清单解析出 0 个模型(响应形状变化?)');
59
+ return models;
60
+ }
61
+ /** 便捷:从身份材料建会话 + 拉模型(适配器与 doctor 共用)。 */
62
+ export async function loadModels(baseUrl, identity) {
63
+ return fetchQoderModels({ baseUrl, identity, session: newCosySession(identity) });
64
+ }