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:
Simon Shi
2026-05-29 17:36:27 +07:00
committed by decolua
parent 468c61b2ac
commit a6fd84691b
20 changed files with 1506 additions and 132 deletions
+83 -70
View File
@@ -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: {