mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
refactor(open-sse): registry consolidation + DRY media/oauth/adhoc cleanup
- Single-source registry: oauth clientId/tokenUrl, usage URLs, image/embed configs, search defaultModel, codex fixedPort, google token url derive. - Remove 29 unused OmniRoute providers (registry 100→71); media intact. - De-adhoc: codex literals → registry format/oauth flags; reasoningInject, image/embed openrouter headers + xai bodyFields config-driven. - Add REGISTRY_TEMPLATE.js + expand PROVIDER_DEFAULTS/schema JSDoc. - Baselines updated; PROVIDERS 62 + alias 90 byte-for-byte, golden snapshots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch.
|
||||
*
|
||||
* Endpoint set:
|
||||
* openapi.qoder.sh - device flow + userinfo + quota usage
|
||||
* center.qoder.sh - token refresh (best-effort, currently 403 for device tokens)
|
||||
* api3.qoder.sh - inference (chat) + model list, requires COSY signing
|
||||
* qoder.com/device - browser landing page for device authorization
|
||||
*/
|
||||
|
||||
export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh";
|
||||
export const QODER_CENTER_BASE = "https://center.qoder.sh";
|
||||
export const QODER_CHAT_BASE = "https://api3.qoder.sh";
|
||||
|
||||
export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts";
|
||||
|
||||
// Device flow endpoints
|
||||
export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`;
|
||||
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
|
||||
export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
|
||||
export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`;
|
||||
|
||||
// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed)
|
||||
export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation";
|
||||
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
|
||||
export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`;
|
||||
export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`;
|
||||
|
||||
// COSY header constants. These are not arbitrary — the upstream signature
|
||||
// validation matches them against the values used at signing time.
|
||||
export const QODER_IDE_VERSION = "1.0.0";
|
||||
export const QODER_CLIENT_TYPE = "5";
|
||||
export const QODER_DATA_POLICY = "disagree";
|
||||
export const QODER_LOGIN_VERSION = "v2";
|
||||
export const QODER_MACHINE_OS = "x86_64_windows";
|
||||
export const QODER_MACHINE_TYPE = "5";
|
||||
|
||||
// Canonical model identifiers. Identity map — keep as a map so callers can
|
||||
// cheaply test "is this a known qoder model?" before sending the request.
|
||||
export const QODER_MODEL_MAP = {
|
||||
// Tier models
|
||||
auto: "auto",
|
||||
ultimate: "ultimate",
|
||||
performance: "performance",
|
||||
efficient: "efficient",
|
||||
lite: "lite",
|
||||
// Frontier models
|
||||
qmodel: "qmodel",
|
||||
qmodel_latest: "qmodel_latest",
|
||||
dmodel: "dmodel",
|
||||
dfmodel: "dfmodel",
|
||||
gm51model: "gm51model",
|
||||
kmodel: "kmodel",
|
||||
mmodel: "mmodel",
|
||||
};
|
||||
|
||||
// RSA public key for COSY encryption (extracted from Qoder IDE v0.9).
|
||||
// Matches the CLIProxyAPIPlus branch and live qodercli traffic.
|
||||
export const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
||||
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
||||
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
||||
XcW+ML9FoCI6AOvOzwIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Qoder COSY (hybrid RSA+AES+MD5) signing, ported from CLIProxyAPIPlus
|
||||
* qoder-provider branch (internal/auth/qoder/cosy.go).
|
||||
*
|
||||
* Every signed request carries:
|
||||
* - an AES-128-CBC payload of the user info, the AES key wrapped in RSA
|
||||
* - an MD5 signature over `payload || cosyKey || timestamp || body || sigPath`
|
||||
* - the body's MD5 hash + length so the server can validate integrity
|
||||
* - 17 Cosy-* / X-* headers fingerprinting the client (machine id, IDE
|
||||
* version, organization id, etc.)
|
||||
*
|
||||
* The on-the-wire header keys use the same casing as qodercli:
|
||||
* Cosy-Machineid, not Cosy-MachineID.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
QODER_CLIENT_TYPE,
|
||||
QODER_DATA_POLICY,
|
||||
QODER_IDE_VERSION,
|
||||
QODER_LOGIN_VERSION,
|
||||
QODER_MACHINE_OS,
|
||||
QODER_MACHINE_TYPE,
|
||||
QODER_RSA_PUBLIC_KEY,
|
||||
} from "./constants.js";
|
||||
|
||||
// AES-128 wants a 16-byte key. Match qodercli/Veria: take the first 16 chars
|
||||
// of a fresh UUID's canonical string (hyphens included). The key is fresh
|
||||
// per request so even though the IV reuses the key bytes, each request still
|
||||
// has a unique IV.
|
||||
function generateAesKey() {
|
||||
return uuidv4().slice(0, 16);
|
||||
}
|
||||
|
||||
function pkcs7Pad(data, blockSize) {
|
||||
const padding = blockSize - (data.length % blockSize);
|
||||
const padded = Buffer.alloc(data.length + padding, padding);
|
||||
data.copy(padded, 0);
|
||||
return padded;
|
||||
}
|
||||
|
||||
function aesEncryptCbcBase64(plaintext, keyStr) {
|
||||
const keyBytes = Buffer.from(keyStr, "utf8");
|
||||
if (keyBytes.length !== 16) {
|
||||
throw new Error(`aes key must be 16 bytes, got ${keyBytes.length}`);
|
||||
}
|
||||
const iv = keyBytes.subarray(0, 16);
|
||||
const cipher = crypto.createCipheriv("aes-128-cbc", keyBytes, iv);
|
||||
cipher.setAutoPadding(false);
|
||||
const padded = pkcs7Pad(Buffer.from(plaintext, "utf8"), 16);
|
||||
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
function rsaEncryptBase64(data) {
|
||||
const encrypted = crypto.publicEncrypt(
|
||||
{ key: QODER_RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||
Buffer.from(data, "utf8"),
|
||||
);
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
function encryptUserInfo(userInfo) {
|
||||
const aesKey = generateAesKey();
|
||||
const plaintext = JSON.stringify(userInfo);
|
||||
const infoB64 = aesEncryptCbcBase64(plaintext, aesKey);
|
||||
const cosyKeyB64 = rsaEncryptBase64(aesKey);
|
||||
return { cosyKey: cosyKeyB64, info: infoB64 };
|
||||
}
|
||||
|
||||
function md5Hex(input) {
|
||||
return crypto.createHash("md5").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the leading "/algo" prefix from the request path. Matches qodercli
|
||||
* convention. Empty input returns "".
|
||||
*/
|
||||
function computeSigPath(requestUrl) {
|
||||
let pathname;
|
||||
try {
|
||||
pathname = new URL(requestUrl).pathname || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (pathname.startsWith("/algo")) {
|
||||
return pathname.slice("/algo".length);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh machine UUID. Persisted on the connection record so
|
||||
* every request from the same auth carries the same machineId.
|
||||
*/
|
||||
export function generateMachineId() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full Cosy-* header set for a single Qoder request.
|
||||
*
|
||||
* @param {Buffer|Uint8Array|string} body The exact bytes that will be sent.
|
||||
* For GET requests pass an empty Buffer / "".
|
||||
* @param {string} requestUrl Full request URL (used for sigPath).
|
||||
* @param {object} creds
|
||||
* @param {string} creds.userId Stable Qoder user id.
|
||||
* @param {string} creds.authToken Device access token (`dt-...`).
|
||||
* @param {string} [creds.name] Display name (optional).
|
||||
* @param {string} [creds.email] Email (optional, can be empty).
|
||||
* @param {string} [creds.machineId] Persisted machine UUID.
|
||||
* @returns {Record<string, string>} Header map ready to merge onto fetch().
|
||||
*/
|
||||
export function buildCosyHeaders(body, requestUrl, creds) {
|
||||
if (!creds?.userId) throw new Error("cosy: user id is empty");
|
||||
if (!creds?.authToken) throw new Error("cosy: auth token is empty");
|
||||
|
||||
const bodyBuf = Buffer.isBuffer(body)
|
||||
? body
|
||||
: typeof body === "string"
|
||||
? Buffer.from(body, "latin1")
|
||||
: Buffer.from(body || []);
|
||||
|
||||
const { cosyKey, info } = encryptUserInfo({
|
||||
uid: creds.userId,
|
||||
security_oauth_token: creds.authToken,
|
||||
name: creds.name || "",
|
||||
aid: "",
|
||||
email: creds.email || "",
|
||||
});
|
||||
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const requestId = uuidv4();
|
||||
|
||||
const payloadJson = JSON.stringify({
|
||||
version: "v1",
|
||||
requestId,
|
||||
info,
|
||||
cosyVersion: QODER_IDE_VERSION,
|
||||
ideVersion: "",
|
||||
});
|
||||
const payloadB64 = Buffer.from(payloadJson, "utf8").toString("base64");
|
||||
|
||||
const sigPath = computeSigPath(requestUrl);
|
||||
const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyBuf.toString("latin1")}\n${sigPath}`;
|
||||
const sig = md5Hex(Buffer.from(sigInput, "latin1"));
|
||||
|
||||
const machineId = creds.machineId || generateMachineId();
|
||||
const bodyHash = md5Hex(bodyBuf);
|
||||
const bodyLength = String(bodyBuf.length);
|
||||
|
||||
return {
|
||||
Authorization: `Bearer COSY.${payloadB64}.${sig}`,
|
||||
"Cosy-Key": cosyKey,
|
||||
"Cosy-User": creds.userId,
|
||||
"Cosy-Date": timestamp,
|
||||
"Cosy-Version": QODER_IDE_VERSION,
|
||||
"Cosy-Machineid": machineId,
|
||||
"Cosy-Machinetoken": machineId,
|
||||
"Cosy-Machinetype": QODER_MACHINE_TYPE,
|
||||
"Cosy-Machineos": QODER_MACHINE_OS,
|
||||
"Cosy-Clienttype": QODER_CLIENT_TYPE,
|
||||
"Cosy-Clientip": "127.0.0.1",
|
||||
"Cosy-Bodyhash": bodyHash,
|
||||
"Cosy-Bodylength": bodyLength,
|
||||
"Cosy-Sigpath": sigPath,
|
||||
"Cosy-Data-Policy": QODER_DATA_POLICY,
|
||||
"Cosy-Organization-Id": "",
|
||||
"Cosy-Organization-Tags": "",
|
||||
"Login-Version": QODER_LOGIN_VERSION,
|
||||
"X-Request-Id": uuidv4(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Qoder body encoding ported from qoder2api's QoderEncoding.java (via the
|
||||
* CLIProxyAPIPlus qoder-provider branch).
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. base64-encode the plaintext bytes (standard alphabet).
|
||||
* 2. Rearrange: split into thirds, reorder as [tail][mid][head].
|
||||
* 3. Substitute each character via a custom alphabet mapping.
|
||||
*
|
||||
* The encoded body must be sent with `&Encode=1` appended to the URL so the
|
||||
* server decodes in reverse. The obfuscation prevents Alibaba Cloud WAF from
|
||||
* pattern-matching the plaintext request body.
|
||||
*/
|
||||
|
||||
const QODER_STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const QODER_CUSTOM_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
|
||||
|
||||
const QODER_S2C = (() => {
|
||||
const table = new Int16Array(128).fill(-1);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
table[QODER_STD_ALPHABET.charCodeAt(i)] = QODER_CUSTOM_ALPHABET.charCodeAt(i);
|
||||
}
|
||||
table["=".charCodeAt(0)] = "$".charCodeAt(0);
|
||||
return table;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Encode plaintext bytes/string using Qoder's WAF-bypass scheme.
|
||||
* @param {Buffer|Uint8Array|string} plaintext
|
||||
* @returns {string} encoded string
|
||||
*/
|
||||
export function qoderEncodeBody(plaintext) {
|
||||
const buf = Buffer.isBuffer(plaintext)
|
||||
? plaintext
|
||||
: typeof plaintext === "string"
|
||||
? Buffer.from(plaintext, "utf8")
|
||||
: Buffer.from(plaintext);
|
||||
|
||||
const std = buf.toString("base64");
|
||||
const n = std.length;
|
||||
const a = Math.floor(n / 3);
|
||||
// [tail][mid][head]
|
||||
const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
|
||||
|
||||
const out = Buffer.alloc(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = rearranged.charCodeAt(i);
|
||||
if (c < 128 && QODER_S2C[c] >= 0) {
|
||||
out[i] = QODER_S2C[c];
|
||||
} else {
|
||||
out[i] = c;
|
||||
}
|
||||
}
|
||||
return out.toString("latin1");
|
||||
}
|
||||
Reference in New Issue
Block a user