waygate-qoder 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Qoder CN 网关的 COSY 协议原语(v0.6.0)。
3
+ *
4
+ * 全部算法逆向自官方客户端 + 社区参考实现(EchoPing07/Qoder-2API-Go 的 auth.go),
5
+ * 并于 2026-10-11 对真网关实弹验证通过(model/list / user/status / chat 三发三中,
6
+ * 见记忆 waygate-qoder-integration 与 POC C:\tmp\qoder-poc.mjs)。
7
+ *
8
+ * 出站安全:所有头走本模块构造,绝不透传入站头(与 headers.ts 的 allowlist 纪律一致);
9
+ * 凭证(dt-/drt- 令牌)只进 AES 加密的 info 块,不落日志。
10
+ */
11
+ /** 客户端 CLI 版本(2026-10-11 本机实测,来源:.qoder-cn/logs/runs 下的 manifest.json)。 */
12
+ export declare const QODER_CLI_VERSION = "1.1.61";
13
+ /** COSY 协议版本(客户端写死值,随客户端升级漂移)。 */
14
+ export declare const QODER_COSY_VERSION = "0.1.43";
15
+ /** 客户端类型标识(5 = CLI 形态,实测值)。 */
16
+ export declare const QODER_CLIENT_TYPE = "5";
17
+ /** 与官方 CLI 同族形态的出站 UA(node 段动态取运行时,同族即可 —— 见 ua-fingerprint-plan.md)。 */
18
+ export declare const QODER_OUT_UA: string;
19
+ /** 登录协议版本(写死 v2)。 */
20
+ export declare const QODER_LOGIN_VERSION = "v2";
21
+ /** [v0.6.0] Qoder 协议的默认网关与聊天端点(chat_path 可按提供商覆写)。 */
22
+ export declare const DEFAULT_QODER_BASE_URL = "https://gateway.qoder.com.cn";
23
+ export declare const DEFAULT_QODER_CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
24
+ /** 标准请求体 → 网关自定义编码(所有 Encode=1 端点的请求体格式)。 */
25
+ export declare function qEncode(plain: string): string;
26
+ /** 网关自定义编码 → 原文(响应侧解码用;非法字符抛错而不是静默截断)。 */
27
+ export declare function qDecode(encoded: string): string;
28
+ /** 提供商凭证文件(`providers/<id>.qoder.json`)里的身份材料。
29
+ * 来自官方客户端本地登录态(auth.v1.dat),由 tools/qoder-import-credential.mjs 导入。 */
30
+ export interface QoderIdentity {
31
+ /** 访问令牌(dt-…)。过期后需从客户端重新导入(本服务不做刷新 —— 刷新会轮换
32
+ * refresh token,连带把用户桌面客户端的登录态顶掉,见 translate 注释)。 */
33
+ token: string;
34
+ /** 刷新令牌(drt-…)。当前仅透传进身份块(网关校验其存在),不用于主动刷新。 */
35
+ refreshToken: string;
36
+ /** 用户 UUID(cosy-user 头与身份块都用)。 */
37
+ uid: string;
38
+ /** 显示名(身份块要求非空即可)。 */
39
+ name: string;
40
+ /** 账号档位(user/status 的 userType,如 personal_standard)。 */
41
+ userType: string;
42
+ }
43
+ /** 一次会话的加密材料。tempKey 仅本地持有;cosyKey/info 每请求头携带。 */
44
+ export interface CosySession {
45
+ cosyKey: string;
46
+ info: string;
47
+ }
48
+ /** 组装 COSY 会话:tempKey=uuid[:16](AES-128,IV=key),cosyKey=RSA(tempKey),
49
+ * info=AES-CBC(身份 JSON)。会话可复用(与签名无关),按身份缓存即可。 */
50
+ export declare function newCosySession(identity: QoderIdentity): CosySession;
51
+ /** 请求签名:md5(payload \n cosyKey \n date \n body \n path)。path 剥 /algo 前缀。 */
52
+ export declare function signCosyRequest(payloadB64: string, cosyKey: string, date: string, body: string, pathWithoutAlgo: string): string;
53
+ /** 剥掉 /algo 前缀的签名用路径(网关双形态路径共用此规则)。 */
54
+ export declare function sigPath(urlPath: string): string;
55
+ /** 组装完整 COSY bearer:`Bearer COSY.<payloadB64>.<sig>`。date=unix 秒(字符串)。 */
56
+ export declare function buildCosyBearer(session: CosySession, unixDate: string, encodedBody: string, urlPath: string): string;
57
+ export interface MachineIdentity {
58
+ machineId: string;
59
+ machineToken: string;
60
+ machineType: string;
61
+ }
62
+ /** 进程级单例:同一进程内所有请求共用一套机器身份(与官方客户端行为一致)。 */
63
+ export declare function machineIdentity(): MachineIdentity;
64
+ /** 组装 COSY bearer 路径的公共头(出站全部头在此构造 —— 不透传入站头)。 */
65
+ export declare function cosyChatHeaders(session: CosySession, identity: QoderIdentity, bearer: string, unixDate: string, accept: string): Record<string, string>;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Qoder CN 网关的 COSY 协议原语(v0.6.0)。
3
+ *
4
+ * 全部算法逆向自官方客户端 + 社区参考实现(EchoPing07/Qoder-2API-Go 的 auth.go),
5
+ * 并于 2026-10-11 对真网关实弹验证通过(model/list / user/status / chat 三发三中,
6
+ * 见记忆 waygate-qoder-integration 与 POC C:\tmp\qoder-poc.mjs)。
7
+ *
8
+ * 出站安全:所有头走本模块构造,绝不透传入站头(与 headers.ts 的 allowlist 纪律一致);
9
+ * 凭证(dt-/drt- 令牌)只进 AES 加密的 info 块,不落日志。
10
+ */
11
+ import { createHash, publicEncrypt, createCipheriv, randomUUID } from 'node:crypto';
12
+ /* ─── 指纹常量(版本字典:客户端升级漂移时只改这里,单测钉住) ─── */
13
+ /** 客户端 CLI 版本(2026-10-11 本机实测,来源:.qoder-cn/logs/runs 下的 manifest.json)。 */
14
+ export const QODER_CLI_VERSION = '1.1.61';
15
+ /** COSY 协议版本(客户端写死值,随客户端升级漂移)。 */
16
+ export const QODER_COSY_VERSION = '0.1.43';
17
+ /** 客户端类型标识(5 = CLI 形态,实测值)。 */
18
+ export const QODER_CLIENT_TYPE = '5';
19
+ /** 与官方 CLI 同族形态的出站 UA(node 段动态取运行时,同族即可 —— 见 ua-fingerprint-plan.md)。 */
20
+ export const QODER_OUT_UA = `qodercli/${QODER_CLI_VERSION} node/${process.versions.node}`;
21
+ /** 登录协议版本(写死 v2)。 */
22
+ export const QODER_LOGIN_VERSION = 'v2';
23
+ /** [v0.6.0] Qoder 协议的默认网关与聊天端点(chat_path 可按提供商覆写)。 */
24
+ export const DEFAULT_QODER_BASE_URL = 'https://gateway.qoder.com.cn';
25
+ export const DEFAULT_QODER_CHAT_PATH = '/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1';
26
+ /* ─── 自定义 base64(Qoder 网关变体:字母表替换 + 三段轮转 + '='→'$') ─── */
27
+ const CUSTOM_ALPHABET = '_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!';
28
+ const STD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
29
+ const S2C = new Map();
30
+ const C2S = new Map();
31
+ /* 两表等长(64 项,构造时同源生成);i<64 时下标必然有效 —— 数组字面量的下标在
32
+ 类型层是 string|undefined,这里用非空断言表达该不变量。 */
33
+ for (let i = 0; i < 64; i++) {
34
+ S2C.set(STD_ALPHABET[i], CUSTOM_ALPHABET[i]);
35
+ C2S.set(CUSTOM_ALPHABET[i], STD_ALPHABET[i]);
36
+ }
37
+ S2C.set('=', '$');
38
+ C2S.set('$', '=');
39
+ /** 标准请求体 → 网关自定义编码(所有 Encode=1 端点的请求体格式)。 */
40
+ export function qEncode(plain) {
41
+ const std = Buffer.from(plain, 'utf8').toString('base64');
42
+ const n = std.length;
43
+ const a = Math.floor(n / 3);
44
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
45
+ let out = '';
46
+ for (const ch of rearranged) {
47
+ const mapped = S2C.get(ch);
48
+ if (mapped === undefined)
49
+ throw new Error(`qEncode 遇到字母表外字符: ${JSON.stringify(ch)}`);
50
+ out += mapped;
51
+ }
52
+ return out;
53
+ }
54
+ /** 网关自定义编码 → 原文(响应侧解码用;非法字符抛错而不是静默截断)。 */
55
+ export function qDecode(encoded) {
56
+ let std = '';
57
+ for (const ch of encoded) {
58
+ const mapped = C2S.get(ch);
59
+ if (mapped === undefined)
60
+ throw new Error(`qDecode 遇到字母表外字符: ${JSON.stringify(ch)}`);
61
+ std += mapped;
62
+ }
63
+ const n = std.length;
64
+ const a = Math.floor(n / 3);
65
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
66
+ return Buffer.from(rearranged, 'base64').toString('utf8');
67
+ }
68
+ /* ─── COSY 会话(身份块加密 + 请求签名) ─── */
69
+ /** 网关服务端 RSA 公钥(客户端内写死值;加密临时 AES key 用)。 */
70
+ const SERVER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
71
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
72
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
73
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
74
+ XcW+ML9FoCI6AOvOzwIDAQAB
75
+ -----END PUBLIC KEY-----`;
76
+ const md5hex = (s) => createHash('md5').update(s, 'utf8').digest('hex');
77
+ /** 组装 COSY 会话:tempKey=uuid[:16](AES-128,IV=key),cosyKey=RSA(tempKey),
78
+ * info=AES-CBC(身份 JSON)。会话可复用(与签名无关),按身份缓存即可。 */
79
+ export function newCosySession(identity) {
80
+ const tempKey = Buffer.from(randomUUID().slice(0, 16), 'utf8');
81
+ const cosyKey = publicEncrypt({ key: SERVER_PUBLIC_KEY, padding: 1 /* RSA_PKCS1_PADDING */ }, tempKey).toString('base64');
82
+ /* 身份块字段序照社区实现(Go struct 序)—— 服务端解密后按 JSON 解析,
83
+ 序不参与校验,但保持一致便于对照排查。 */
84
+ const plain = Buffer.from(JSON.stringify({
85
+ name: identity.name,
86
+ aid: '',
87
+ uid: identity.uid,
88
+ yx_uid: '',
89
+ organization_id: '',
90
+ organization_name: '',
91
+ user_type: identity.userType,
92
+ security_oauth_token: identity.token,
93
+ refresh_token: identity.refreshToken,
94
+ }), 'utf8');
95
+ const padLen = 16 - (plain.length % 16);
96
+ const padded = Buffer.concat([plain, Buffer.alloc(padLen, padLen)]);
97
+ const cipher = createCipheriv('aes-128-cbc', tempKey, tempKey.slice(0, 16));
98
+ cipher.setAutoPadding(false);
99
+ const info = Buffer.concat([cipher.update(padded), cipher.final()]).toString('base64');
100
+ return { cosyKey, info };
101
+ }
102
+ /** bearer 载荷(cosyVersion/ideVersion/info/requestId/version 五字段,base64)。
103
+ * requestId 每请求新生成。 */
104
+ function buildPayloadB64(info) {
105
+ return Buffer.from(JSON.stringify({
106
+ cosyVersion: QODER_COSY_VERSION,
107
+ ideVersion: '',
108
+ info,
109
+ requestId: randomUUID(),
110
+ version: 'v1',
111
+ }), 'utf8').toString('base64');
112
+ }
113
+ /** 请求签名:md5(payload \n cosyKey \n date \n body \n path)。path 剥 /algo 前缀。 */
114
+ export function signCosyRequest(payloadB64, cosyKey, date, body, pathWithoutAlgo) {
115
+ return md5hex([payloadB64, cosyKey, date, body, pathWithoutAlgo].join('\n'));
116
+ }
117
+ /** 剥掉 /algo 前缀的签名用路径(网关双形态路径共用此规则)。 */
118
+ export function sigPath(urlPath) {
119
+ return urlPath.startsWith('/algo') ? urlPath.slice('/algo'.length) : urlPath;
120
+ }
121
+ /** 组装完整 COSY bearer:`Bearer COSY.<payloadB64>.<sig>`。date=unix 秒(字符串)。 */
122
+ export function buildCosyBearer(session, unixDate, encodedBody, urlPath) {
123
+ const payloadB64 = buildPayloadB64(session.info);
124
+ const sig = signCosyRequest(payloadB64, session.cosyKey, unixDate, encodedBody, sigPath(urlPath));
125
+ return `Bearer COSY.${payloadB64}.${sig}`;
126
+ }
127
+ let machineSingleton = null;
128
+ /** 进程级单例:同一进程内所有请求共用一套机器身份(与官方客户端行为一致)。 */
129
+ export function machineIdentity() {
130
+ if (!machineSingleton) {
131
+ machineSingleton = {
132
+ machineId: randomUUID(),
133
+ machineToken: (randomUUID() + randomUUID()).slice(0, 50),
134
+ machineType: randomUUID().slice(0, 18),
135
+ };
136
+ }
137
+ return machineSingleton;
138
+ }
139
+ /** 组装 COSY bearer 路径的公共头(出站全部头在此构造 —— 不透传入站头)。 */
140
+ export function cosyChatHeaders(session, identity, bearer, unixDate, accept) {
141
+ const m = machineIdentity();
142
+ return {
143
+ 'cosy-data-policy': 'AGREE',
144
+ 'content-type': 'application/json',
145
+ 'cosy-machinetype': m.machineType,
146
+ 'cosy-clienttype': QODER_CLIENT_TYPE,
147
+ 'cosy-date': unixDate,
148
+ 'cosy-user': identity.uid,
149
+ 'cosy-key': session.cosyKey,
150
+ accept,
151
+ authorization: bearer,
152
+ 'accept-encoding': 'identity',
153
+ 'cosy-version': QODER_COSY_VERSION,
154
+ 'cosy-machineid': m.machineId,
155
+ 'cosy-machinetoken': m.machineToken,
156
+ 'login-version': QODER_LOGIN_VERSION,
157
+ 'user-agent': QODER_OUT_UA,
158
+ };
159
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 入站解码:Qoder SSE → OpenAI SSE chunk 流(v0.6.0)。
3
+ *
4
+ * 网关帧形状(agent_chat_generation,Encode=1):`data: {"body":"<JSON 字符串>",…}` ——
5
+ * 双层 JSON:wrapper.body 是**字符串化的 OpenAI 形状 chunk**(choices[].delta /
6
+ * usage / finish_reason),实测 2026-10-11(见记忆 waygate-qoder-integration)。
7
+ *
8
+ * usage 口径(本文件的要害):
9
+ * · 网关 usage 带 Qoder 扩展字段 `credits`(名义费率值)与 `billable`(是否实际扣费);
10
+ * · 落库铁律(反代保真):`credit`(本服务落库的唯一权威字段)= **billable ? credits : 0**
11
+ * —— 免费档 billable:false 的真实扣费就是 0;名义值不冒充实扣(与 v0.5.8 口径一致);
12
+ * · billable 字段缺失按 true(社区同判:缺失模型不静默清零积分);
13
+ * · reasoning / cached tokens 进标准 details 位 —— extractReasoningTokens /
14
+ * extractCachedTokens 现有链路直接可读。
15
+ *
16
+ * 思考流:delta.reasoning_content → 原样下发(DeepSeek 惯例字段,ZCode 等客户端认识)。
17
+ * 流结束信号:网关直接关流(无显式 stop 帧)→ flush() 补收尾 usage chunk + [DONE]。
18
+ */
19
+ /** 转换流:Qoder SSE → OpenAI SSE。网关关流时 flush 补 usage 收尾帧 + [DONE]。
20
+ * `model` 由调用方注入(路由解析出的目录 id)—— 网关响应里的 model 是 'auto'
21
+ * 占位,不能用(见 onInnerChunk 的注释);注入后 chunk 与落库取的模型名都正确。 */
22
+ export declare function qoderToOpenAiSse(model?: string): TransformStream<Uint8Array, Uint8Array>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 入站解码:Qoder SSE → OpenAI SSE chunk 流(v0.6.0)。
3
+ *
4
+ * 网关帧形状(agent_chat_generation,Encode=1):`data: {"body":"<JSON 字符串>",…}` ——
5
+ * 双层 JSON:wrapper.body 是**字符串化的 OpenAI 形状 chunk**(choices[].delta /
6
+ * usage / finish_reason),实测 2026-10-11(见记忆 waygate-qoder-integration)。
7
+ *
8
+ * usage 口径(本文件的要害):
9
+ * · 网关 usage 带 Qoder 扩展字段 `credits`(名义费率值)与 `billable`(是否实际扣费);
10
+ * · 落库铁律(反代保真):`credit`(本服务落库的唯一权威字段)= **billable ? credits : 0**
11
+ * —— 免费档 billable:false 的真实扣费就是 0;名义值不冒充实扣(与 v0.5.8 口径一致);
12
+ * · billable 字段缺失按 true(社区同判:缺失模型不静默清零积分);
13
+ * · reasoning / cached tokens 进标准 details 位 —— extractReasoningTokens /
14
+ * extractCachedTokens 现有链路直接可读。
15
+ *
16
+ * 思考流:delta.reasoning_content → 原样下发(DeepSeek 惯例字段,ZCode 等客户端认识)。
17
+ * 流结束信号:网关直接关流(无显式 stop 帧)→ flush() 补收尾 usage chunk + [DONE]。
18
+ */
19
+ import { UpstreamError } from '../errors.js';
20
+ function chunkJson(state, delta, finish, withUsage = false) {
21
+ const chunk = {
22
+ id: state.id,
23
+ object: 'chat.completion.chunk',
24
+ created: state.created,
25
+ model: state.model,
26
+ choices: [{ index: 0, delta, finish_reason: finish }],
27
+ };
28
+ if (withUsage && state.usage)
29
+ chunk.usage = state.usage;
30
+ return JSON.stringify(chunk);
31
+ }
32
+ /** 解析单个内层 chunk(OpenAI 形状),返回应下发的行(null = 无输出)。 */
33
+ function onInnerChunk(state, inner) {
34
+ /* usage 两种形态都收:独立末帧(choices 空)或挂在最后一个内容帧上(GLM 式)。 */
35
+ const usage = inner.usage;
36
+ if (usage && typeof usage === 'object') {
37
+ const prompt = typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : 0;
38
+ const completion = typeof usage.completion_tokens === 'number' ? usage.completion_tokens : 0;
39
+ const cached = (usage.prompt_tokens_details?.cached_tokens ?? 0);
40
+ const reasoning = (usage.completion_tokens_details?.reasoning_tokens ?? 0);
41
+ /* ★ credits 口径:billable 显式为 false → 实扣 0;缺失/true → 名义 credits 值。
42
+ credits 缺失且 billable=true → credit 不设(extractUpstreamCredit 判 unknown)。 */
43
+ let credit;
44
+ if (usage.billable === false)
45
+ credit = 0;
46
+ else if (typeof usage.credits === 'number' && Number.isFinite(usage.credits) && usage.credits >= 0) {
47
+ credit = Math.round(usage.credits * 100) / 100;
48
+ }
49
+ state.usage = {
50
+ prompt_tokens: prompt,
51
+ completion_tokens: completion,
52
+ total_tokens: typeof usage.total_tokens === 'number' ? usage.total_tokens : prompt + completion,
53
+ prompt_tokens_details: { cached_tokens: cached },
54
+ completion_tokens_details: { reasoning_tokens: reasoning },
55
+ ...(credit !== undefined ? { credit } : {}),
56
+ };
57
+ state.sawContentOrUsage = true;
58
+ }
59
+ if (typeof inner.id === 'string' && inner.id && !state.id)
60
+ state.id = inner.id;
61
+ /* ⚠️ inner.model 一律**丢弃**:网关回的是 'auto'(它自己的会话标签,不是模型名)——
62
+ 实测 2026-10-11 全链路:落库 model 被写成 'auto',会让按模型聚合塌成一桶。
63
+ 落库取的 model 由 ctx.modelId(路由解析出的目录 id)兜底,正是我们要的真值。 */
64
+ const choices = inner.choices;
65
+ if (!Array.isArray(choices) || choices.length === 0)
66
+ return null;
67
+ /* choices.length === 0 已在上一行挡掉;此处下标必然有效。 */
68
+ const choice = choices[0];
69
+ const delta = (choice.delta ?? {});
70
+ const out = {};
71
+ if (typeof delta.role === 'string' && delta.role)
72
+ out.role = delta.role;
73
+ if (typeof delta.content === 'string' && delta.content)
74
+ out.content = delta.content;
75
+ if (typeof delta.reasoning_content === 'string' && delta.reasoning_content)
76
+ out.reasoning_content = delta.reasoning_content;
77
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0)
78
+ out.tool_calls = delta.tool_calls;
79
+ const finish = typeof choice.finish_reason === 'string' ? choice.finish_reason : null;
80
+ if (finish)
81
+ state.finish = finish;
82
+ const hasOut = Object.keys(out).length > 0;
83
+ if (hasOut)
84
+ state.sawContentOrUsage = true;
85
+ /* usage 挂在内容帧上时(GLM 式)也要把 usage 带出去 —— 但聚合器取「最后一个非空
86
+ usage」,独立末帧模式由 flush() 兜底补发,这里只在有内容时附带。 */
87
+ return hasOut ? chunkJson(state, out, finish) : null;
88
+ }
89
+ /** 处理一帧(可能多行),返回应下发的行列表。 */
90
+ function handleFrame(state, frame) {
91
+ const out = [];
92
+ for (const line of frame.split('\n')) {
93
+ if (!line.startsWith('data:'))
94
+ continue;
95
+ const payload = line.slice(5).trim();
96
+ if (!payload || payload === '[DONE]')
97
+ continue;
98
+ let wrapper;
99
+ try {
100
+ wrapper = JSON.parse(payload);
101
+ }
102
+ catch {
103
+ continue; // 坏帧跳过(与 openai 路径同口径)
104
+ }
105
+ /* 流内错误信封(业务码伪装成功流):网关 10605 准入拒绝等会以
106
+ statusCodeValue/错误体出现在帧里 —— 抛 RelayHttpError 让主管道按失败落库。 */
107
+ const errBody = wrapper.error;
108
+ if (errBody) {
109
+ const msg = typeof errBody === 'string' ? errBody : String(errBody.message ?? 'unknown');
110
+ throw new UpstreamError(`上游(qoder)流内错误:${msg}`);
111
+ }
112
+ const bodyStr = wrapper.body;
113
+ if (typeof bodyStr !== 'string' || bodyStr === '')
114
+ continue;
115
+ let inner;
116
+ try {
117
+ inner = JSON.parse(bodyStr);
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const line2 = onInnerChunk(state, inner);
123
+ if (line2 !== null)
124
+ out.push(`data: ${line2}\n\n`);
125
+ }
126
+ return out;
127
+ }
128
+ /** 转换流:Qoder SSE → OpenAI SSE。网关关流时 flush 补 usage 收尾帧 + [DONE]。
129
+ * `model` 由调用方注入(路由解析出的目录 id)—— 网关响应里的 model 是 'auto'
130
+ * 占位,不能用(见 onInnerChunk 的注释);注入后 chunk 与落库取的模型名都正确。 */
131
+ export function qoderToOpenAiSse(model = '') {
132
+ const state = {
133
+ id: '',
134
+ model,
135
+ created: Math.floor(Date.now() / 1000),
136
+ finish: null,
137
+ sawContentOrUsage: false,
138
+ usage: null,
139
+ };
140
+ const encoder = new TextEncoder();
141
+ const decoder = new TextDecoder();
142
+ let buffer = '';
143
+ const handle = (frame, emit) => {
144
+ for (const line of handleFrame(state, frame))
145
+ emit(line);
146
+ };
147
+ return new TransformStream({
148
+ transform(chunk, controller) {
149
+ const emit = (s) => controller.enqueue(encoder.encode(s));
150
+ buffer += decoder.decode(chunk, { stream: true });
151
+ let sep;
152
+ while ((sep = buffer.indexOf('\n\n')) >= 0) {
153
+ const frame = buffer.slice(0, sep);
154
+ buffer = buffer.slice(sep + 2);
155
+ handle(frame, emit);
156
+ }
157
+ },
158
+ flush(controller) {
159
+ const emit = (s) => controller.enqueue(encoder.encode(s));
160
+ const tail = buffer + decoder.decode();
161
+ if (tail.trim() !== '')
162
+ handle(tail, emit);
163
+ /* 网关关流 = 结束信号(无显式 stop 帧):补收尾帧(带 usage)+ [DONE]。
164
+ ⚠️ 一帧内容/usage 都没见过就关流 = 上游异常 —— 不补帧不补 DONE,
165
+ readUpstreamSse 按既有断流语义判失败(绝不伪造成功空流)。 */
166
+ if (!state.sawContentOrUsage)
167
+ return;
168
+ const fin = chunkJson(state, {}, state.finish ?? 'stop', true);
169
+ emit(`data: ${fin}\n\n`);
170
+ emit('data: [DONE]\n\n');
171
+ },
172
+ });
173
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 出站翻译:OpenAI Chat Completions → Qoder agent_chat_generation(v0.6.0)。
3
+ *
4
+ * 与 anthropic-upstream/translate.ts(OpenAI→Anthropic)同族:入口收本服务统一的
5
+ * ChatRequestBody(已过 prompt 改写),出口是 Qoder 网关的请求体(JSON 字符串 ——
6
+ * 外层再由 cosy.ts 的 qEncode 编码)。
7
+ *
8
+ * 消息折叠策略(v1 口径,与 Anthropic 路线一致):
9
+ * · system 消息 → 顶部 system 块?Qoder 无独立 system 字段 —— 并入首条 user 文本
10
+ * (网关侧 chat_context 才是「本轮输入」的主通道,messages 是会话历史);
11
+ * · role:"tool" 的工具结果 → 序列化并进紧随的 user 消息文本(Anthropic 路线同款);
12
+ * · assistant 的 tool_calls 历史 → 序列化为其文本的一部分(v1 不回灌原生
13
+ * tool_calls 块 —— 客户端通常重发完整历史,序列化保语义);
14
+ * · 未识别字段静默忽略(与两个入站方向口径一致)。
15
+ *
16
+ * 参数透传(verbatim 政策,与社区实现一致):temperature/top_p/stop 等 **原样**进
17
+ * parameters;网关是裁判。max_tokens 缺省补 8192(官方客户端自己的回落)。
18
+ * reasoning_effort 若客户端给了(o 系参数),映射 model_config.is_reasoning。
19
+ */
20
+ import type { ChatMessage, ChatRequestBody } from 'waygate-core';
21
+ /** Qoder 消息(v1:纯文本形态 + 空白 response_meta)。 */
22
+ interface QoderMessage {
23
+ role: 'user' | 'assistant';
24
+ content: string;
25
+ response_meta: {
26
+ id: string;
27
+ usage: Record<string, number | object>;
28
+ };
29
+ reasoning_content_signature: string;
30
+ contents: Array<{
31
+ type: 'text';
32
+ text: string;
33
+ }>;
34
+ }
35
+ /** 折叠 OpenAI 消息序列为 Qoder user/assistant 交替历史 + 提取本轮 prompt。 */
36
+ export declare function buildQoderMessages(messages: ChatMessage[]): {
37
+ history: QoderMessage[];
38
+ prompt: string;
39
+ };
40
+ /** Qoder 请求体(ChatRequestBody 镜像,字段序照社区实现 —— 序列化形状稳定)。 */
41
+ export interface QoderRequestBody {
42
+ request_id: string;
43
+ request_set_id: string;
44
+ chat_record_id: string;
45
+ stream: boolean;
46
+ chat_task: string;
47
+ chat_context: {
48
+ extra: {
49
+ modelConfig: {
50
+ is_reasoning: boolean;
51
+ key: string;
52
+ };
53
+ originalContent: {
54
+ type: string;
55
+ text: string;
56
+ };
57
+ };
58
+ text: {
59
+ type: string;
60
+ text: string;
61
+ };
62
+ };
63
+ session_id: string;
64
+ source: number;
65
+ version: string;
66
+ aliyun_user_type: string;
67
+ session_type: string;
68
+ agent_id: string;
69
+ task_id: string;
70
+ model_config: {
71
+ key: string;
72
+ is_vl: boolean;
73
+ is_reasoning: boolean;
74
+ source: string;
75
+ };
76
+ messages: QoderMessage[];
77
+ business: {
78
+ id: string;
79
+ name: string;
80
+ begin_at: number;
81
+ };
82
+ parameters: Record<string, unknown>;
83
+ tools?: unknown;
84
+ }
85
+ /** 组装 Qoder 请求体。qoderModelKey = 目录 id(即网关内部 key,如 qfmodel)。 */
86
+ export declare function toQoderRequest(body: ChatRequestBody, qoderModelKey: string, userType: string): QoderRequestBody;
87
+ export {};
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 出站翻译:OpenAI Chat Completions → Qoder agent_chat_generation(v0.6.0)。
3
+ *
4
+ * 与 anthropic-upstream/translate.ts(OpenAI→Anthropic)同族:入口收本服务统一的
5
+ * ChatRequestBody(已过 prompt 改写),出口是 Qoder 网关的请求体(JSON 字符串 ——
6
+ * 外层再由 cosy.ts 的 qEncode 编码)。
7
+ *
8
+ * 消息折叠策略(v1 口径,与 Anthropic 路线一致):
9
+ * · system 消息 → 顶部 system 块?Qoder 无独立 system 字段 —— 并入首条 user 文本
10
+ * (网关侧 chat_context 才是「本轮输入」的主通道,messages 是会话历史);
11
+ * · role:"tool" 的工具结果 → 序列化并进紧随的 user 消息文本(Anthropic 路线同款);
12
+ * · assistant 的 tool_calls 历史 → 序列化为其文本的一部分(v1 不回灌原生
13
+ * tool_calls 块 —— 客户端通常重发完整历史,序列化保语义);
14
+ * · 未识别字段静默忽略(与两个入站方向口径一致)。
15
+ *
16
+ * 参数透传(verbatim 政策,与社区实现一致):temperature/top_p/stop 等 **原样**进
17
+ * parameters;网关是裁判。max_tokens 缺省补 8192(官方客户端自己的回落)。
18
+ * reasoning_effort 若客户端给了(o 系参数),映射 model_config.is_reasoning。
19
+ */
20
+ import { randomUUID } from 'node:crypto';
21
+ const BLANK_USAGE = {
22
+ prompt_tokens: 0,
23
+ completion_tokens: 0,
24
+ total_tokens: 0,
25
+ completion_tokens_details: { reasoning_tokens: 0 },
26
+ prompt_tokens_details: { cached_tokens: 0 },
27
+ };
28
+ function qMsg(role, text) {
29
+ return {
30
+ role,
31
+ content: '',
32
+ response_meta: { id: '', usage: { ...BLANK_USAGE, completion_tokens_details: { ...BLANK_USAGE.completion_tokens_details }, prompt_tokens_details: { ...BLANK_USAGE.prompt_tokens_details } } },
33
+ reasoning_content_signature: '',
34
+ contents: [{ type: 'text', text }],
35
+ };
36
+ }
37
+ /** 工具调用历史序列化(人类可读、保 id/name/arguments 全量)。 */
38
+ function serializeToolCalls(calls) {
39
+ if (!Array.isArray(calls))
40
+ return '';
41
+ return calls
42
+ .map((c) => {
43
+ const fn = c.function ?? {};
44
+ return `[tool_call id=${c.id ?? ''} name=${fn.name ?? ''}] ${fn.arguments ?? ''}`;
45
+ })
46
+ .join('\n');
47
+ }
48
+ /** 折叠 OpenAI 消息序列为 Qoder user/assistant 交替历史 + 提取本轮 prompt。 */
49
+ export function buildQoderMessages(messages) {
50
+ const folded = [];
51
+ /** 待并入下一条 user 的碎片(system 前缀 / tool 结果 / assistant 工具调用说明)。 */
52
+ let pendingUserPrefix = [];
53
+ let latestUserPrompt = '';
54
+ for (const m of messages) {
55
+ const text = typeof m.content === 'string' ? m.content : m.content == null ? '' : JSON.stringify(m.content);
56
+ if (m.role === 'system') {
57
+ pendingUserPrefix.push(`[system] ${text}`);
58
+ continue;
59
+ }
60
+ if (m.role === 'tool') {
61
+ pendingUserPrefix.push(`[tool_result ${String(m.tool_call_id ?? '')}] ${text}`);
62
+ continue;
63
+ }
64
+ if (m.role === 'assistant') {
65
+ const tc = serializeToolCalls(m.tool_calls);
66
+ const full = tc ? `${text}\n${tc}` : text;
67
+ if (full.trim())
68
+ folded.push(qMsg('assistant', full));
69
+ continue;
70
+ }
71
+ /* user(未知角色按 user 处理 —— 与网关宽容口径一致) */
72
+ const merged = [...pendingUserPrefix, text].filter((s) => s !== '').join('\n\n');
73
+ pendingUserPrefix = [];
74
+ latestUserPrompt = merged;
75
+ folded.push(qMsg('user', merged));
76
+ }
77
+ /* 残余 system/tool 碎片(序列以非 user 结尾的病态情况):并入最后一条 user。 */
78
+ if (pendingUserPrefix.length > 0 && folded.length > 0) {
79
+ const last = folded[folded.length - 1];
80
+ if (last && last.role === 'user') {
81
+ last.contents = [{ type: 'text', text: `${last.contents[0]?.text ?? ''}\n\n${pendingUserPrefix.join('\n\n')}` }];
82
+ latestUserPrompt = last.contents[0]?.text ?? '';
83
+ }
84
+ else {
85
+ folded.push(qMsg('user', pendingUserPrefix.join('\n\n')));
86
+ latestUserPrompt = pendingUserPrefix.join('\n\n');
87
+ }
88
+ pendingUserPrefix = [];
89
+ }
90
+ if (!latestUserPrompt && folded.length > 0) {
91
+ const last = folded[folded.length - 1];
92
+ if (last && last.role === 'user')
93
+ latestUserPrompt = last.contents[0]?.text ?? '';
94
+ }
95
+ return { history: folded, prompt: latestUserPrompt };
96
+ }
97
+ /** parameters 透传白名单(其余字段不进 parameters —— verbatim 政策内只搬已知键,
98
+ * 防止把 stream_options 之类的 OpenAI 控制字段误投给网关)。 */
99
+ const PARAM_PASSTHROUGH = ['temperature', 'top_p', 'stop', 'frequency_penalty', 'presence_penalty', 'seed', 'response_format', 'tool_choice', 'reasoning_effort', 'max_thinking_tokens'];
100
+ /** 组装 Qoder 请求体。qoderModelKey = 目录 id(即网关内部 key,如 qfmodel)。 */
101
+ export function toQoderRequest(body, qoderModelKey, userType) {
102
+ const { history, prompt } = buildQoderMessages(body.messages);
103
+ const nid = randomUUID();
104
+ const isReasoning = body.reasoning_effort != null && body.reasoning_effort !== 'none';
105
+ const parameters = {};
106
+ for (const k of PARAM_PASSTHROUGH) {
107
+ if (body[k] !== undefined)
108
+ parameters[k] = body[k];
109
+ }
110
+ if (body.max_tokens !== undefined)
111
+ parameters.max_tokens = body.max_tokens;
112
+ else if (parameters.max_tokens === undefined)
113
+ parameters.max_tokens = 8192; // 官方客户端自己的回落
114
+ return {
115
+ request_id: nid,
116
+ request_set_id: randomUUID(),
117
+ chat_record_id: nid,
118
+ stream: true, // 恒开流(管道流式原生,与 anthropic 路线同口径)
119
+ chat_task: 'FREE_INPUT',
120
+ chat_context: {
121
+ extra: {
122
+ modelConfig: { is_reasoning: isReasoning, key: qoderModelKey },
123
+ originalContent: { type: 'text', text: prompt },
124
+ },
125
+ text: { type: 'text', text: prompt },
126
+ },
127
+ session_id: randomUUID(),
128
+ source: 1,
129
+ version: '3',
130
+ aliyun_user_type: userType,
131
+ session_type: 'qodercli',
132
+ agent_id: 'agent_common',
133
+ task_id: 'common',
134
+ model_config: { key: qoderModelKey, is_vl: true, is_reasoning: isReasoning, source: 'system' },
135
+ messages: history,
136
+ business: { id: randomUUID(), name: prompt.slice(0, 30) || 'chat', begin_at: Date.now() },
137
+ parameters,
138
+ tools: Array.isArray(body.tools) ? body.tools : undefined,
139
+ };
140
+ }