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 })),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user