mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(qoder): port Kiro-style provider integration with COSY signing
Replaces the Qoder placeholder with a real free-tier provider: - Device-flow OAuth: PKCE + nonce generated locally, user authorizes at qoder.com/device/selectAccounts, poll openapi.qoder.sh until token - COSY signing (RSA-1024 + AES-128-CBC + MD5) for chat / model-list - WAF-bypass body encoding (custom-alphabet base64 + thirds rearrange) - Live model_config catalog from /algo/api/v2/model/list, cached 1h - 11 models registered (auto/ultimate/performance/efficient/lite + 6 frontier *model ids) - Usage fetcher for openapi.qoder.sh/api/v2/quota/usage - Dashboard live-models resolver, provider test, OAuth modal hookup - 24 unit tests covering encoder, PKCE, COSY headers, sigPath stripping
This commit is contained in:
@@ -151,7 +151,7 @@ export async function GET(request, { params }) {
|
||||
: undefined;
|
||||
|
||||
// Providers that don't use PKCE for device code
|
||||
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy"];
|
||||
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy", "qoder"];
|
||||
let deviceData;
|
||||
if (noPkceDeviceProviders.includes(provider)) {
|
||||
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
|
||||
@@ -162,7 +162,9 @@ export async function GET(request, { params }) {
|
||||
|
||||
return NextResponse.json({
|
||||
...deviceData,
|
||||
codeVerifier: authData.codeVerifier,
|
||||
// Prefer the verifier the provider's requestDeviceCode generated for
|
||||
// itself (qoder rolls its own PKCE pair); fall back to the generic one.
|
||||
codeVerifier: deviceData.codeVerifier || authData.codeVerifier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,6 +278,14 @@ export async function POST(request, { params }) {
|
||||
} else if (provider === "kiro") {
|
||||
// Kiro needs extraData (clientId, clientSecret) from device code response
|
||||
result = await pollForToken(provider, deviceCode, null, extraData);
|
||||
} else if (provider === "qoder") {
|
||||
// Qoder needs both the PKCE verifier (codeVerifier) and the machineId
|
||||
// captured at device-code time (extraData._qoderMachineId) so
|
||||
// mapTokens can persist it for COSY signing.
|
||||
if (!codeVerifier) {
|
||||
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
|
||||
}
|
||||
result = await pollForToken(provider, deviceCode, codeVerifier, extraData);
|
||||
} else {
|
||||
// Qwen and other PKCE providers
|
||||
if (!codeVerifier) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
|
||||
@@ -286,6 +287,41 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
return { models: [], warning };
|
||||
}
|
||||
},
|
||||
qoder: {
|
||||
customResolver: async (connection) => {
|
||||
const credentials = {
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
email: connection.email,
|
||||
displayName: connection.displayName,
|
||||
providerSpecificData: connection.providerSpecificData || {},
|
||||
};
|
||||
let warning;
|
||||
try {
|
||||
const result = await resolveQoderModels(credentials, { forceRefresh: true });
|
||||
if (result?.models?.length) {
|
||||
return {
|
||||
models: result.models.map((m) => ({
|
||||
// Use the canonical "qoder/<key>" id so the dashboard
|
||||
// surfaces the same identifier the chat router expects.
|
||||
id: `qoder/${m.id}`,
|
||||
name: m.name,
|
||||
contextLength: m.contextLength,
|
||||
isVL: m.isVL,
|
||||
isReasoning: m.isReasoning,
|
||||
maxOutputTokens: m.maxOutputTokens,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
warning = "Qoder returned no models; falling back to static catalog.";
|
||||
} catch (error) {
|
||||
warning = `Failed to fetch Qoder models: ${error.message}`;
|
||||
console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message);
|
||||
}
|
||||
return { models: [], warning };
|
||||
},
|
||||
},
|
||||
"gemini-cli": {
|
||||
customResolver: buildOAuthResolver({
|
||||
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),
|
||||
|
||||
@@ -61,6 +61,15 @@ const OAUTH_TEST_CONFIG = {
|
||||
},
|
||||
qwen: { checkExpiry: true, refreshable: true },
|
||||
kiro: { checkExpiry: true, refreshable: true },
|
||||
qoder: {
|
||||
// Test by hitting Qoder's userinfo endpoint with the device token.
|
||||
url: "https://openapi.qoder.sh/api/v1/userinfo",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
checkExpiry: true,
|
||||
refreshable: false,
|
||||
},
|
||||
"kimi-coding": { checkExpiry: true, refreshable: false },
|
||||
cursor: { tokenExists: true },
|
||||
kilocode: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
|
||||
// Per-provider live model resolvers. Each receives a connection record and
|
||||
// returns { models: [{ id, name? }, ...] } | null on failure.
|
||||
@@ -20,6 +21,19 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
providerSpecificData: conn.providerSpecificData || {}
|
||||
}, { log: console });
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
qoder: async (conn) => {
|
||||
const result = await resolveQoderModels({
|
||||
accessToken: conn.accessToken,
|
||||
refreshToken: conn.refreshToken,
|
||||
email: conn.email,
|
||||
displayName: conn.displayName,
|
||||
providerSpecificData: conn.providerSpecificData || {}
|
||||
});
|
||||
if (!result?.models?.length) return null;
|
||||
return {
|
||||
models: result.models.map((m) => ({ id: m.id, name: m.name })),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,15 +63,20 @@ export const QWEN_CONFIG = {
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
// Qoder OAuth Configuration (Device Token Flow)
|
||||
// Qoder OAuth Configuration (Device Token Flow with PKCE).
|
||||
// Device tokens are long-lived (~30 days for access, ~360 for refresh).
|
||||
// The upstream refresh endpoint at center.qoder.sh returns 403 for our
|
||||
// flow — we accept that and surface it to the user as "re-login" instead
|
||||
// of attempting to silently rotate.
|
||||
export const QODER_CONFIG = {
|
||||
apiBaseUrl: "https://api2.qoder.sh",
|
||||
deviceTokenUrl: "https://api2.qoder.sh/api/v1/deviceToken/poll",
|
||||
deviceRefreshUrl: "https://api2.qoder.sh/api/v1/deviceToken/refresh",
|
||||
refreshUrl: "https://api2.qoder.sh/api/v3/user/refresh_token",
|
||||
userInfoUrl: "https://api2.qoder.sh/api/v1/userinfo",
|
||||
statusUrl: "https://api2.qoder.sh/api/v3/user/status",
|
||||
loginUrl: "https://qoder.com/login",
|
||||
openApiBaseUrl: "https://openapi.qoder.sh",
|
||||
centerBaseUrl: "https://center.qoder.sh",
|
||||
chatBaseUrl: "https://api3.qoder.sh",
|
||||
deviceTokenUrl: "https://openapi.qoder.sh/api/v1/deviceToken/poll",
|
||||
refreshUrl: "https://center.qoder.sh/algo/api/v3/user/refresh_token",
|
||||
userInfoUrl: "https://openapi.qoder.sh/api/v1/userinfo",
|
||||
quotaUsageUrl: "https://openapi.qoder.sh/api/v2/quota/usage",
|
||||
loginUrl: "https://qoder.com/device/selectAccounts",
|
||||
};
|
||||
|
||||
// iFlow OAuth Configuration (Authorization Code)
|
||||
|
||||
+83
-70
@@ -600,80 +600,93 @@ const PROVIDERS = {
|
||||
|
||||
qoder: {
|
||||
config: QODER_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
flowType: "device_code",
|
||||
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
|
||||
// locally, the user lands on qoder.com/device/selectAccounts in the
|
||||
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
|
||||
requestDeviceCode: async (config) => {
|
||||
const { initiateDeviceFlow } = await import("@/lib/qoder/auth");
|
||||
const flow = initiateDeviceFlow();
|
||||
// Match the device_code shape the rest of the OAuthModal expects
|
||||
// (device_code, user_code, verification_uri[_complete], interval).
|
||||
// The poll endpoint identifies us by nonce+verifier, not by a
|
||||
// server-issued device_code, so we plumb our own values through:
|
||||
// device_code = nonce (modal forwards as deviceCode on poll)
|
||||
// codeVerifier = our PKCE verifier (route forwards as codeVerifier)
|
||||
return {
|
||||
device_code: flow.nonce,
|
||||
user_code: flow.nonce.slice(0, 8).toUpperCase(),
|
||||
verification_uri: config.loginUrl,
|
||||
verification_uri_complete: flow.verificationUriComplete,
|
||||
expires_in: 300,
|
||||
interval: 2,
|
||||
codeVerifier: flow.codeVerifier,
|
||||
_qoderNonce: flow.nonce,
|
||||
_qoderMachineId: flow.machineId,
|
||||
};
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||
const { pollDeviceToken, fetchUserInfo } = await import("@/lib/qoder/auth");
|
||||
const nonce = deviceCode || extraData?._qoderNonce;
|
||||
const verifier = codeVerifier || extraData?._qoderVerifier;
|
||||
if (!nonce || !verifier) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "invalid_request", error_description: "Missing nonce/verifier" },
|
||||
};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await pollDeviceToken({ nonce, codeVerifier: verifier });
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "poll_failed", error_description: err.message },
|
||||
};
|
||||
}
|
||||
if (result.status === "pending") {
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
}
|
||||
// Best-effort profile lookup so we have a name/email to display.
|
||||
const userInfo = await fetchUserInfo(result.accessToken);
|
||||
// expireTime is a Unix-ms timestamp from parseExpiry, which already
|
||||
// falls back to "now + 30 days" when the upstream omits expiry. Floor
|
||||
// to a sane minimum (1 day) so a stale or skewed upstream timestamp
|
||||
// doesn't truncate the stored token below something useful.
|
||||
const minSeconds = 24 * 60 * 60;
|
||||
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
|
||||
const expiresIn = Math.max(minSeconds, remainingSeconds);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: result.accessToken,
|
||||
refresh_token: result.refreshToken,
|
||||
expires_in: expiresIn,
|
||||
_qoderUserId: result.userId,
|
||||
_qoderMachineId: extraData?._qoderMachineId || "",
|
||||
_qoderName: userInfo.name,
|
||||
_qoderEmail: userInfo.email,
|
||||
_qoderOrganizationId: userInfo.organizationId,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info (MUST succeed to get API key)
|
||||
const userInfoRes = await fetch(
|
||||
`${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
|
||||
if (!userInfoRes.ok) {
|
||||
const errorText = await userInfoRes.text();
|
||||
throw new Error(`Failed to fetch user info: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await userInfoRes.json();
|
||||
if (!result.success) {
|
||||
throw new Error(`User info request failed: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
const userInfo = result.data || {};
|
||||
|
||||
if (!userInfo.apiKey || userInfo.apiKey.trim() === "") {
|
||||
throw new Error("Empty API key returned from Qoder");
|
||||
}
|
||||
|
||||
const email = userInfo.email?.trim() || userInfo.phone?.trim();
|
||||
if (!email) {
|
||||
throw new Error("Missing account email/phone in user info");
|
||||
}
|
||||
|
||||
return { userInfo };
|
||||
mapTokens: (tokens) => {
|
||||
const email = (tokens._qoderEmail || "").trim() || null;
|
||||
const displayName = (tokens._qoderName || "").trim() || null;
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
expiresIn: tokens.expires_in,
|
||||
email,
|
||||
displayName,
|
||||
providerSpecificData: {
|
||||
authMethod: "device",
|
||||
userId: tokens._qoderUserId || "",
|
||||
machineId: tokens._qoderMachineId || "",
|
||||
organizationId: tokens._qoderOrganizationId || "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
},
|
||||
|
||||
qwen: {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Qoder device flow authentication.
|
||||
*
|
||||
* The flow has three steps:
|
||||
* 1. Generate a PKCE pair locally and a fresh nonce + machine id.
|
||||
* 2. Open https://qoder.com/device/selectAccounts?challenge=...&nonce=...
|
||||
* in the user's browser.
|
||||
* 3. Poll openapi.qoder.sh/api/v1/deviceToken/poll until the user authorizes
|
||||
* and the upstream returns a `dt-...` access token.
|
||||
*
|
||||
* Tokens live ~30 days; refresh is a no-op (the upstream refresh endpoint
|
||||
* returns 403 for our flow). Users re-run login when expired.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
QODER_DEVICE_TOKEN_URL,
|
||||
QODER_LOGIN_URL,
|
||||
QODER_USERINFO_URL,
|
||||
} from "./constants.js";
|
||||
|
||||
function base64Url(buf) {
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PKCE verifier + S256 challenge pair.
|
||||
* Uses 32 random bytes (matches qodercli/Veria).
|
||||
*/
|
||||
export function generatePkcePair() {
|
||||
const verifier = base64Url(crypto.randomBytes(32));
|
||||
const challenge = base64Url(crypto.createHash("sha256").update(verifier).digest());
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate the device flow. Returns the URL to open in a browser plus the
|
||||
* verifier/nonce/machineId we'll need to poll and to sign future requests.
|
||||
*/
|
||||
export function initiateDeviceFlow() {
|
||||
const { verifier, challenge } = generatePkcePair();
|
||||
const nonce = uuidv4();
|
||||
const machineId = uuidv4();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
challenge,
|
||||
challenge_method: "S256",
|
||||
machine_id: machineId,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return {
|
||||
verificationUriComplete: `${QODER_LOGIN_URL}?${params.toString()}`,
|
||||
codeVerifier: verifier,
|
||||
nonce,
|
||||
machineId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single poll attempt. Returns one of:
|
||||
* { status: "pending" } — keep polling
|
||||
* { status: "ok", token, ... } — user authorized, tokens captured
|
||||
* throws Error — terminal failure
|
||||
*
|
||||
* Upstream returns 202/404 while waiting; 200 with a JSON body when done.
|
||||
*/
|
||||
export async function pollDeviceToken({ nonce, codeVerifier }) {
|
||||
if (!nonce || !codeVerifier) {
|
||||
throw new Error("pollDeviceToken: missing nonce or code verifier");
|
||||
}
|
||||
const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Go-http-client/2.0",
|
||||
},
|
||||
});
|
||||
|
||||
// Pending — server has registered the device code but the user hasn't
|
||||
// finished the browser flow yet. Both 202 and 404 mean "keep polling".
|
||||
if (response.status === 202 || response.status === 404) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Qoder device token poll failed: HTTP ${response.status}`;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body.message) message = `Qoder device token poll failed: ${body.message}`;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new Error(`Qoder device token poll: invalid JSON response (${err.message})`);
|
||||
}
|
||||
|
||||
// Defensive: 200 + empty token means the upstream changed shape.
|
||||
if (!body.token) {
|
||||
throw new Error("Qoder device token poll returned 200 but no token");
|
||||
}
|
||||
|
||||
const expireMs = parseExpiry(body.expires_at, body.expires_in);
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
accessToken: body.token,
|
||||
refreshToken: body.refresh_token || "",
|
||||
userId: body.user_id || "",
|
||||
expireTime: expireMs,
|
||||
rawResponse: body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile info for the freshly-issued token. Best-effort — failures
|
||||
* shouldn't block login; returning empty strings is fine.
|
||||
*/
|
||||
export async function fetchUserInfo(accessToken) {
|
||||
try {
|
||||
const response = await fetch(QODER_USERINFO_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Go-http-client/2.0",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return { name: "", email: "" };
|
||||
const body = await response.json();
|
||||
return {
|
||||
name: (body.name || body.username || "").trim(),
|
||||
email: (body.email || "").trim(),
|
||||
organizationId: (body.organization_id || "").trim(),
|
||||
};
|
||||
} catch {
|
||||
return { name: "", email: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the upstream's expiry hint into a Unix-millisecond timestamp.
|
||||
* Accepts RFC3339 strings, ms-epoch integer strings, or seconds-from-now
|
||||
* (`expires_in`). Falls back to "now + 30 days" when both are missing.
|
||||
*/
|
||||
function parseExpiry(expiresAt, expiresInSeconds) {
|
||||
const trimmed = typeof expiresAt === "string" ? expiresAt.trim() : "";
|
||||
if (trimmed) {
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
const ms = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(ms) && ms > 0) return ms;
|
||||
}
|
||||
if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) {
|
||||
return Date.now() + expiresInSeconds * 1000;
|
||||
}
|
||||
return Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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",
|
||||
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");
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
setError(null);
|
||||
|
||||
// Device code flow providers
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy"];
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy", "qoder"];
|
||||
if (deviceCodeProviders.includes(provider)) {
|
||||
setIsDeviceCode(true);
|
||||
setStep("waiting");
|
||||
@@ -175,7 +175,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret) and
|
||||
// Qoder (contains _qoderMachineId / _qoderNonce — needed so mapTokens
|
||||
// can persist the machine id alongside the token).
|
||||
const extraData = provider === "kiro"
|
||||
? {
|
||||
_clientId: data._clientId,
|
||||
@@ -184,6 +186,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
_authMethod: data._authMethod,
|
||||
_startUrl: data._startUrl,
|
||||
}
|
||||
: provider === "qoder"
|
||||
? {
|
||||
_qoderNonce: data._qoderNonce,
|
||||
_qoderMachineId: data._qoderMachineId,
|
||||
_qoderVerifier: data.codeVerifier,
|
||||
}
|
||||
: null;
|
||||
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
||||
return;
|
||||
|
||||
@@ -9,7 +9,7 @@ export const FREE_PROVIDERS = {
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://qoder.com", notice: { signupUrl: "https://qoder.com" } },
|
||||
// iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode Free", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user