diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 48f796d5..4a064cb5 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -144,6 +144,21 @@ export const PROVIDER_MODELS = { { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, ], + qd: [ // Qoder AI - tier + frontier models (server-published catalog) + // Tier models — pick a quality/cost tradeoff + { id: "auto", name: "Qoder Auto" }, + { id: "ultimate", name: "Qoder Ultimate" }, + { id: "performance", name: "Qoder Performance" }, + { id: "efficient", name: "Qoder Efficient" }, + { id: "lite", name: "Qoder Lite" }, + // Frontier models — pin a specific backing model + { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, + { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, + { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, + { id: "gm51model", name: "GLM 5.1 (Qoder)" }, + { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, + { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + ], cu: [ // Cursor IDE { id: "default", name: "Auto (Server Picks)" }, { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, @@ -870,6 +885,7 @@ const OAUTH_ALIASES = { kilocode: "kc", cline: "cl", opencode: "oc", + qoder: "qd", vertex: "vertex", "vertex-partner": "vertex-partner", }; diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index e3990d20..8658aba9 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -94,13 +94,13 @@ export const PROVIDERS = { authUrl: "https://iflow.cn/oauth" }, qoder: { - baseUrl: "https://api.qoder.com/v1/chat/completions", + // The qoder executor builds the full URL itself (it has to append + // ?Encode=1 + sigPath query params and bypass any provider-level URL + // rewriting). baseUrl is kept for compatibility with introspection + // helpers but the executor ignores it. + baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", format: "openai", - headers: { "User-Agent": "Qoder-Cli" }, - clientId: process.env.QODER_OAUTH_CLIENT_ID || "10009311001", - clientSecret: process.env.QODER_OAUTH_CLIENT_SECRET || "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", - tokenUrl: "https://api.qoder.com/oauth/token", - authUrl: "https://qoder.com/oauth/authorize" + headers: {}, }, antigravity: { baseUrls: [ diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index a64a229f..e675cafd 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -1,72 +1,404 @@ -import crypto from "crypto"; +/** + * QoderExecutor — sends OpenAI-format chat requests to Qoder's COSY-signed + * inference endpoint at api3.qoder.sh, then unwraps Qoder's `{statusCodeValue, + * body}` SSE envelope back into plain OpenAI SSE for the rest of the pipeline. + * + * Differences vs the previous placeholder: + * - URL is api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation + * with `&Encode=1` so we can ship the body through the WAF-bypass + * encoder. + * - Authentication is COSY (RSA + AES + MD5 + ~17 Cosy-* headers), not + * a static HMAC. + * - The request shape Qoder expects is non-trivial (chat_context with + * mirrored modelConfig, business block with stable IDs, system text + * hoisted out of the messages array). All ported from the reference. + * - Model identifier is one of the canonical 11 keys (auto / ultimate / + * performance / efficient / lite + 6 frontier "*model" ids); the + * translator layer feeds us "qoder/" so we strip the prefix. + * - Per-model `model_config` is fetched live from /algo/api/v2/model/list + * and cached. Sending the wrong block silently downgrades to a + * different model upstream, so a missing entry is a hard error. + */ + +import { qoderEncodeBody } from "@/lib/qoder/encoding.js"; +import { buildCosyHeaders } from "@/lib/qoder/cosy.js"; +import { v4 as uuidv4 } from "uuid"; +import { createHash } from "crypto"; + import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { + QODER_CHAT_URL_ENCODED, + QODER_MODEL_MAP, +} from "@/lib/qoder/constants.js"; +import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; /** - * QoderExecutor - Executor for Qoder API with HMAC-SHA256 signature - * Requires 3 custom headers to avoid 406 error: session-id, x-qoder-timestamp, x-qoder-signature + * Hoist role:"system" messages out of the messages array (Qoder rejects + * system in messages) and flatten any multipart content arrays. */ +function normalizeMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return { messages: [], systemText: "" }; + } + const systemParts = []; + const out = []; + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const text = extractText(msg.content); + if (msg.role === "system") { + if (text) systemParts.push(text); + continue; + } + const cloned = { ...msg }; + cloned.content = text; + out.push(cloned); + } + return { messages: out, systemText: systemParts.join("\n\n") }; +} + +function extractText(content) { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + const parts = []; + for (const item of content) { + if (item && typeof item === "object") { + if (item.type === "text" && typeof item.text === "string") { + parts.push(item.text); + } else if (typeof item.text === "string") { + parts.push(item.text); + } + } + } + return parts.join("\n"); + } + return String(content); +} + +function lastUserText(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role === "user" && typeof m.content === "string") { + return m.content; + } + } + return ""; +} + +function stableHash(prefix, ...parts) { + const h = createHash("sha256"); + h.update(prefix); + for (const p of parts) { + h.update("\0"); + h.update(String(p ?? "")); + } + return h.digest("hex").slice(0, 16); +} + +function stableChatRecordId(model, messages, tools, maxTokens) { + const h = createHash("sha256"); + h.update("qoder-record\0"); + h.update(String(model)); + for (const m of messages) { + if (!m || typeof m !== "object") continue; + if (m.role) { h.update("\0"); h.update(m.role); } + if (typeof m.content === "string" && m.content) { + h.update("\0"); h.update(m.content); + } + } + if (tools) { + h.update("\0"); + try { h.update(JSON.stringify(tools)); } catch {} + } + h.update(`\0mt=${maxTokens}`); + return h.digest("hex").slice(0, 16); +} + +function truncate(s, n) { + return s && s.length > n ? `${s.slice(0, n)}...` : s || ""; +} + +/** + * Map the OpenAI-style request body into the exact shape Qoder expects. + */ +async function buildQoderRequestBody({ model, body, credentials, log }) { + const qoderKey = String(model || "").replace(/^qoder\//, ""); + if (!QODER_MODEL_MAP[qoderKey]) { + throw new Error(`Unsupported qoder model: "${qoderKey}" (received "${model}")`); + } + + let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log }); + if (!modelConfig) { + // Try a forced refresh once before giving up — the cache may simply + // not be populated yet on first ever call for this credential. + const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log }); + const retried = refreshed?.rawConfigs.get(qoderKey); + if (!retried) { + throw new Error( + `qoder: model_config for "${qoderKey}" not yet known (run a model list fetch or check upstream connectivity)`, + ); + } + modelConfig = { ...retried, key: qoderKey }; + } + + const { messages, systemText } = normalizeMessages(body.messages || []); + const tools = body.tools; + const isReasoning = !!modelConfig.is_reasoning; + const maxOutputTokens = Number(modelConfig.max_output_tokens) || 0; + + let maxTokens = 32_768; + if (maxOutputTokens > 0) maxTokens = maxOutputTokens; + if (typeof body.max_tokens === "number" && body.max_tokens > 0 && body.max_tokens < maxTokens) { + maxTokens = body.max_tokens; + } + if (typeof body.max_completion_tokens === "number" && body.max_completion_tokens > 0 && body.max_completion_tokens < maxTokens) { + maxTokens = body.max_completion_tokens; + } + + const lastUser = lastUserText(messages); + const psd = credentials.providerSpecificData || {}; + const sessionId = stableHash("qoder-session", psd.userId, qoderKey); + const recordId = stableChatRecordId(qoderKey, messages, tools, maxTokens); + + return { + qoderKey, + payload: { + request_id: uuidv4(), + request_set_id: recordId, + chat_record_id: recordId, + session_id: sessionId, + stream: true, + chat_task: "FREE_INPUT", + is_reply: true, + is_retry: false, + source: 1, + version: "3", + session_type: "qodercli", + agent_id: "agent_common", + task_id: "common", + code_language: "", + chat_prompt: "", + image_urls: null, + aliyun_user_type: "", + system: systemText, + messages, + tools: Array.isArray(tools) ? tools : [], + parameters: { max_tokens: maxTokens }, + chat_context: { + chatPrompt: "", + imageUrls: null, + extra: { + context: [], + modelConfig: { key: qoderKey, is_reasoning: isReasoning }, + originalContent: lastUser, + }, + features: [], + text: lastUser, + }, + model_config: modelConfig, + business: { + product: "cli", + version: "1.0.0", + type: "agent", + stage: "start", + id: uuidv4(), + name: truncate(lastUser, 30), + begin_at: Date.now(), + }, + }, + modelConfig, + }; +} + +/** + * Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain + * OpenAI SSE chunks the rest of the chatCore pipeline understands. + * + * Each upstream line looks like: + * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} + * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it + * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus + * a synthetic OpenAI error chunk. + */ +function wrapQoderSSE(response, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let doneEmitted = false; + + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + const trimmed = line.replace(/\r$/, "").trim(); + if (!trimmed) continue; + if (!trimmed.startsWith("data:")) continue; + + let data = trimmed.slice(5).trimStart(); + if (data === "[DONE]") { + if (!doneEmitted) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + } + continue; + } + + let envelope; + try { envelope = JSON.parse(data); } catch { continue; } + const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; + const inner = typeof envelope.body === "string" ? envelope.body : ""; + if (statusVal !== 200) { + const msg = inner || `upstream status ${statusVal}`; + const errChunk = JSON.stringify({ + id: `qoder-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }], + }); + controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`)); + if (!doneEmitted) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + } + continue; + } + if (!inner) continue; + if (inner === "[DONE]") { + if (!doneEmitted) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + } + continue; + } + // Inner is already an OpenAI-shaped chunk; forward as-is. + controller.enqueue(encoder.encode(`data: ${inner}\n\n`)); + } + }, + flush(controller) { + if (!doneEmitted) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } + }, + }); + + const transformed = response.body.pipeThrough(transform); + // Build a Response with passable headers; the streaming handler reads + // `.body` as a ReadableStream regardless of Content-Type. + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + export class QoderExecutor extends BaseExecutor { constructor() { super("qoder", PROVIDERS.qoder); } - /** - * Create Qoder signature using HMAC-SHA256 - * Formula: HMAC-SHA256(key=apiKey, message="UserAgent:sessionID:timestamp") - */ - createSignature(userAgent, sessionID, timestamp, apiKey) { - if (!apiKey) return ""; - const payload = `${userAgent}:${sessionID}:${timestamp}`; - const hmac = crypto.createHmac("sha256", apiKey); - hmac.update(payload); - return hmac.digest("hex"); + buildUrl() { + return QODER_CHAT_URL_ENCODED; } - /** - * Build headers with Qoder-specific signature - */ - buildHeaders(credentials, stream = true) { - const sessionID = `session-${crypto.randomUUID()}`; - const timestamp = Date.now(); - const userAgent = this.config.headers["User-Agent"] || "Qoder-Cli"; - const apiKey = credentials.apiKey || credentials.accessToken || ""; + // Override execute entirely — Qoder needs: + // - body built from translated chat completion payload + // - body encoded with QoderEncodeBody before signing + // - COSY headers built from the *encoded* body bytes + // - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const url = this.buildUrl(); - const signature = this.createSignature(userAgent, sessionID, timestamp, apiKey); + const psd = credentials?.providerSpecificData || {}; + if (!psd.userId) { + // No user id → no way to sign. Surface a 401 so the dashboard nudges + // the user back to OAuth. + const fakeResp = new Response( + JSON.stringify({ error: { message: "qoder credential is missing userId; reconnect the account" } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + let qoderKey; + let payload; + try { + ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log })); + } catch (err) { + const fakeResp = new Response( + JSON.stringify({ error: { message: err.message } }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + + const plainBody = Buffer.from(JSON.stringify(payload), "utf8"); + const encodedBodyStr = qoderEncodeBody(plainBody); + const encodedBodyBuf = Buffer.from(encodedBodyStr, "latin1"); + + const cosyHeaders = buildCosyHeaders( + encodedBodyBuf, + url, + { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }, + ); + + const modelSource = (payload.model_config && payload.model_config.source) || "system"; const headers = { "Content-Type": "application/json", - ...this.config.headers, - "session-id": sessionID, - "x-qoder-timestamp": timestamp.toString(), - "x-qoder-signature": signature, + Accept: "text/event-stream", + "Cache-Control": "no-cache", + "X-Model-Key": qoderKey, + "X-Model-Source": modelSource, + // gzip triggers signature validation on Qoder's CDN; force identity. + "Accept-Encoding": "identity", + ...cosyHeaders, }; - if (credentials.apiKey) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; + let response; + try { + response = await proxyAwareFetch( + url, + { method: "POST", headers, body: encodedBodyBuf, signal }, + proxyOptions, + ); + } catch (err) { + throw err; } - if (stream) { - headers["Accept"] = "text/event-stream"; + if (!response.ok) { + // Pass error response through unchanged so chatCore can capture it. + return { response, url, headers, transformedBody: payload }; } - return headers; + const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`); + return { response: wrapped, url, headers, transformedBody: payload }; } - buildUrl(model, stream, urlIndex = 0, credentials = null) { - return this.config.baseUrl; + // Qoder device tokens don't refresh through OAuth — the upstream returns + // 403 for our flow. Surfacing failure via 401-on-chat is enough; the + // dashboard tells users to re-login when their token expires (~30 days). + async refreshCredentials() { + return null; } - /** - * Inject stream_options for usage data on streaming requests - */ - transformRequest(model, body, stream, credentials) { - if (stream && body.messages && !body.stream_options) { - body.stream_options = { include_usage: true }; - } - return body; + needsRefresh() { + return false; } } diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 3f651e84..2bd66e63 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -14,6 +14,8 @@ const ALIAS_TO_PROVIDER_ID = { cl: "cline", oc: "opencode", ocg: "opencode-go", + qd: "qoder", + qoder: "qoder", // TTS providers el: "elevenlabs", // API Key providers diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js new file mode 100644 index 00000000..24cc165e --- /dev/null +++ b/open-sse/services/qoderModels.js @@ -0,0 +1,176 @@ +/** + * Qoder model catalog fetcher. + * + * Calls /algo/api/v2/model/list (COSY-signed) on the inference host to get + * the live catalog for an authenticated Qoder account, then caches the + * per-model `model_config` blocks by key. Chat requests later look up the + * exact server-published metadata for the model they want — Qoder's chat + * endpoint silently downgrades to a different model when the wrong + * model_config is sent. + * + * On any error the live cache stays empty and chatExecuteCall surfaces the + * problem to the user as "model config not yet fetched, retry shortly". + */ + +import { createHash } from "crypto"; + +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { buildCosyHeaders } from "@/lib/qoder/cosy.js"; +import { + QODER_MODEL_LIST_URL, +} from "@/lib/qoder/constants.js"; + +const FETCH_TIMEOUT_MS = 15_000; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog + +/** @type {Map, fetched: boolean }>} */ +const catalogCache = new Map(); + +/** + * Stable cache key per credential (so different login sessions for the same + * account share an entry). + */ +function cacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous"; + return createHash("sha256").update(`qoder:${seed}`).digest("hex"); +} + +/** + * Strip credential -> COSY creds for buildCosyHeaders. + */ +function cosyCredsFromConnection(credentials) { + const psd = credentials?.providerSpecificData || {}; + return { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }; +} + +/** + * Fetch the live model list for this credential. Returns: + * { models: [{ id, name, contextLength, isVL, isReasoning, ... }, ...], + * rawConfigs: Map } + * or `null` on any error. + */ +async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { + const creds = cosyCredsFromConnection(credentials); + if (!creds.userId || !creds.authToken) return null; + + const headers = { + Accept: "application/json", + "Accept-Encoding": "identity", + ...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds), + }; + + const controller = new AbortController(); + let timer = null; + let abortListener = null; + let response; + try { + timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); + if (signal && typeof signal.addEventListener === "function") { + abortListener = () => controller.abort(signal.reason); + signal.addEventListener("abort", abortListener); + } + response = await proxyAwareFetch( + QODER_MODEL_LIST_URL, + { + method: "GET", + headers, + signal: controller.signal, + }, + proxyOptions, + ); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } + + if (!response.ok) return null; + + const body = await response.json().catch(() => null); + if (!body || !Array.isArray(body.chat)) return null; + + const models = []; + const rawConfigs = new Map(); + for (const entry of body.chat) { + if (!entry || typeof entry !== "object") continue; + const key = entry.key; + if (!key) continue; + if (entry.enable === false) continue; + + rawConfigs.set(key, entry); + + const display = entry.display_name || key; + const ctx = Number(entry.max_input_tokens) || 131_072; + models.push({ + id: key, + name: `${display}`, + contextLength: ctx, + isVL: !!entry.is_vl, + isReasoning: !!entry.is_reasoning, + maxOutputTokens: Number(entry.max_output_tokens) || 0, + description: entry.description || "", + }); + } + + return { models, rawConfigs }; +} + +/** + * Get the cached model_config block for a given model key, fetching the + * catalog first if needed. Returns null when the catalog can't be fetched + * (so callers can fall back to the static registry). + */ +export async function getQoderModelConfig(credentials, modelKey, options = {}) { + const cached = await resolveQoderModels(credentials, options); + if (!cached) return null; + const config = cached.rawConfigs.get(modelKey); + if (!config) return null; + // Defensive copy — chat code may mutate `key` to align with the alias path. + return { ...config, key: modelKey }; +} + +/** + * Resolve the live model catalog + raw configs for a credential. Caches + * results for CACHE_TTL_MS so repeated chat requests don't re-fetch. + */ +export async function resolveQoderModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const psd = credentials.providerSpecificData || {}; + if (!psd.userId) return null; + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > now) { + return cached; + } + } + + const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); + if (!fetched) return null; + + const entry = { + expiresAt: now + CACHE_TTL_MS, + models: fetched.models, + rawConfigs: fetched.rawConfigs, + fetched: true, + }; + catalogCache.set(key, entry); + return entry; +} + +export function invalidateQoderCatalog(credentials) { + if (!credentials) return; + catalogCache.delete(cacheKey(credentials)); +} + +export function clearQoderCatalog() { + catalogCache.clear(); +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 26a10f72..e5955072 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -77,6 +77,8 @@ export async function getUsageForProvider(connection, proxyOptions = null) { return await getCodexUsage(accessToken, proxyOptions); case "kiro": return await getKiroUsage(accessToken, providerSpecificData, proxyOptions); + case "qoder": + return await getQoderUsage(accessToken, proxyOptions); case "qwen": return await getQwenUsage(accessToken, providerSpecificData); case "iflow": @@ -1149,3 +1151,51 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." }; } + +async function getQoderUsage(accessToken, proxyOptions = null) { + if (!accessToken) { + return { message: "Qoder usage unavailable: no access token" }; + } + try { + const response = await proxyAwareFetch( + "https://openapi.qoder.sh/api/v2/quota/usage", + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + if (!response.ok) { + return { message: `Qoder connected. Usage fetch returned ${response.status}.` }; + } + const body = await response.json().catch(() => null); + if (!body) { + return { message: "Qoder connected. Usage response was not JSON." }; + } + const userQuota = body.userQuota || {}; + const orgQuota = body.orgResourcePackage || {}; + const quotas = { + user: { + total: Number(userQuota.total) || 0, + used: Number(userQuota.used) || 0, + remaining: Number(userQuota.remaining) || 0, + unit: userQuota.unit || "credits", + }, + organization: { + total: Number(orgQuota.total) || 0, + used: Number(orgQuota.used) || 0, + remaining: Number(orgQuota.remaining) || 0, + unit: orgQuota.unit || "credits", + }, + totalUsagePercentage: Number(body.totalUsagePercentage) || 0, + isQuotaExceeded: !!body.isQuotaExceeded, + expiresAt: Number(body.expiresAt) || null, + }; + return { quotas }; + } catch (error) { + return { message: `Qoder connected. Unable to fetch usage: ${error.message}` }; + } +} diff --git a/public/providers/qoder.png b/public/providers/qoder.png new file mode 100644 index 00000000..41e81c1d Binary files /dev/null and b/public/providers/qoder.png differ diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 60dcef1e..a65b0b4c 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -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) { diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 8ad6051f..793094de 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -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/" 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), diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index fda06929..fc60b7a9 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -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: { diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 4fe297c4..59fa550e 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -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 })), + }; } }; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index ece8cfff..dc825b30 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -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) diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 46b99058..0464d6b5 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -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: { diff --git a/src/lib/qoder/auth.js b/src/lib/qoder/auth.js new file mode 100644 index 00000000..ae0e2b1e --- /dev/null +++ b/src/lib/qoder/auth.js @@ -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; +} diff --git a/src/lib/qoder/constants.js b/src/lib/qoder/constants.js new file mode 100644 index 00000000..54b51549 --- /dev/null +++ b/src/lib/qoder/constants.js @@ -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-----`; diff --git a/src/lib/qoder/cosy.js b/src/lib/qoder/cosy.js new file mode 100644 index 00000000..d5d59af7 --- /dev/null +++ b/src/lib/qoder/cosy.js @@ -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} 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(), + }; +} diff --git a/src/lib/qoder/encoding.js b/src/lib/qoder/encoding.js new file mode 100644 index 00000000..31449e85 --- /dev/null +++ b/src/lib/qoder/encoding.js @@ -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"); +} diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 751bc065..1a6c9c95 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -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; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index d8beadea..ea07fb4a 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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" } }, }; diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js new file mode 100644 index 00000000..a76eb81a --- /dev/null +++ b/tests/unit/qoder.test.js @@ -0,0 +1,238 @@ +/** + * Unit tests for Qoder encoding + COSY signing primitives. + * + * These cover the parts that would silently produce wrong-but-plausible + * output if logic regressed: + * - body encoder boundary cases (empty input, lengths not divisible by 3) + * - COSY header production (signature deterministic given fixed inputs, + * all required headers present, sigPath correctly stripped) + * - device flow URL construction + */ + +import { describe, it, expect } from "vitest"; +import crypto from "crypto"; + +import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js"; +import { buildCosyHeaders } from "../../src/lib/qoder/cosy.js"; +import { initiateDeviceFlow, generatePkcePair } from "../../src/lib/qoder/auth.js"; +import { QODER_CHAT_URL_ENCODED, QODER_MODEL_LIST_URL } from "../../src/lib/qoder/constants.js"; + +describe("qoderEncodeBody", () => { + it("preserves base64 length (input length divisible by 3)", () => { + const input = Buffer.from("abcdef", "utf8"); // 6 bytes → 8 base64 chars + const encoded = qoderEncodeBody(input); + expect(encoded.length).toBe(8); + }); + + it("preserves base64 length (input length not divisible by 3)", () => { + const input = Buffer.from("hello", "utf8"); // 5 bytes → 8 base64 chars (with padding) + const encoded = qoderEncodeBody(input); + expect(encoded.length).toBe(8); + }); + + it("handles empty input without throwing", () => { + const encoded = qoderEncodeBody(Buffer.alloc(0)); + expect(encoded).toBe(""); + }); + + it("accepts string and Buffer inputs equivalently", () => { + const a = qoderEncodeBody("hello"); + const b = qoderEncodeBody(Buffer.from("hello", "utf8")); + expect(a).toBe(b); + }); + + it("only emits characters from the custom alphabet", () => { + // The custom alphabet is "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!" + // plus "$" for the padding char. If the substitution step regresses, + // characters outside that set would leak into the output. + const allowed = new Set( + "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!$", + ); + const encoded = qoderEncodeBody( + "hello world this is a longer string for testing 0123456789", + ); + for (const ch of encoded) { + expect(allowed.has(ch), `unexpected char in output: ${JSON.stringify(ch)}`).toBe(true); + } + }); + + it("is deterministic for identical input", () => { + const a = qoderEncodeBody("abc"); + const b = qoderEncodeBody("abc"); + expect(a).toBe(b); + }); + + it("produces different output for different input", () => { + const a = qoderEncodeBody("abc"); + const b = qoderEncodeBody("xyz"); + expect(a).not.toBe(b); + }); +}); + +describe("generatePkcePair", () => { + it("produces base64url-safe verifier and challenge of the right length", () => { + const { verifier, challenge } = generatePkcePair(); + // 32 bytes → 43 base64url chars (no padding) + expect(verifier.length).toBe(43); + expect(challenge.length).toBe(43); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("verifier and challenge are different (challenge is sha256 of verifier)", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier).not.toBe(challenge); + // S256: challenge should be base64url(sha256(verifier)) + const expected = crypto + .createHash("sha256") + .update(verifier) + .digest("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + expect(challenge).toBe(expected); + }); + + it("returns codeVerifier (not verifier) on the higher-level helper", () => { + // Regression: the providers.js qoder entry once read flow.verifier (undefined) + // because initiateDeviceFlow returns the field as `codeVerifier`. + const flow = initiateDeviceFlow(); + expect(typeof flow.codeVerifier).toBe("string"); + expect(flow.codeVerifier.length).toBe(43); + expect(flow.verifier).toBeUndefined(); + }); +}); + +describe("initiateDeviceFlow", () => { + it("produces a verification URL pointing at qoder.com/device/selectAccounts", () => { + const flow = initiateDeviceFlow(); + expect(flow.verificationUriComplete).toMatch( + /^https:\/\/qoder\.com\/device\/selectAccounts\?/, + ); + expect(flow.verificationUriComplete).toContain("challenge_method=S256"); + expect(flow.verificationUriComplete).toContain(`nonce=${flow.nonce}`); + expect(flow.verificationUriComplete).toContain(`machine_id=${flow.machineId}`); + }); + + it("returns nonce and machineId as UUIDs", () => { + const flow = initiateDeviceFlow(); + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + expect(flow.nonce).toMatch(uuidRe); + expect(flow.machineId).toMatch(uuidRe); + }); +}); + +describe("buildCosyHeaders", () => { + const creds = { + userId: "test-user-id", + authToken: "dt-test-token", + name: "Test", + email: "test@example.com", + machineId: "fixed-machine-id", + }; + + it("produces all required Cosy-* headers", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + const required = [ + "Authorization", + "Cosy-Key", + "Cosy-User", + "Cosy-Date", + "Cosy-Version", + "Cosy-Machineid", + "Cosy-Machinetoken", + "Cosy-Machinetype", + "Cosy-Machineos", + "Cosy-Clienttype", + "Cosy-Clientip", + "Cosy-Bodyhash", + "Cosy-Bodylength", + "Cosy-Sigpath", + "Cosy-Data-Policy", + "Login-Version", + "X-Request-Id", + ]; + for (const key of required) { + expect(headers[key], `missing header ${key}`).toBeDefined(); + } + }); + + it("Authorization is a Bearer COSY token with payload+sig", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + expect(headers.Authorization).toMatch(/^Bearer COSY\.[A-Za-z0-9+/=]+\.[a-f0-9]{32}$/); + }); + + it("Cosy-Sigpath strips the leading /algo prefix", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + expect(headers["Cosy-Sigpath"]).toBe("/api/v2/model/list"); + }); + + it("Cosy-Sigpath also handles the encoded chat URL", () => { + const headers = buildCosyHeaders(Buffer.from("body", "utf8"), QODER_CHAT_URL_ENCODED, creds); + expect(headers["Cosy-Sigpath"]).toBe( + "/api/v2/service/pro/sse/agent_chat_generation", + ); + }); + + it("Cosy-Bodyhash is the MD5 of the request body, Cosy-Bodylength is the length", () => { + const body = Buffer.from("hello qoder", "utf8"); + const headers = buildCosyHeaders(body, QODER_MODEL_LIST_URL, creds); + const expectedHash = crypto.createHash("md5").update(body).digest("hex"); + expect(headers["Cosy-Bodyhash"]).toBe(expectedHash); + expect(headers["Cosy-Bodylength"]).toBe(String(body.length)); + }); + + it("empty body produces the canonical empty-MD5 hash", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + expect(headers["Cosy-Bodyhash"]).toBe("d41d8cd98f00b204e9800998ecf8427e"); + expect(headers["Cosy-Bodylength"]).toBe("0"); + }); + + it("Cosy-Machineid + Cosy-Machinetoken match the supplied machineId", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + expect(headers["Cosy-Machineid"]).toBe("fixed-machine-id"); + expect(headers["Cosy-Machinetoken"]).toBe("fixed-machine-id"); + }); + + it("auto-generates a machineId when none is supplied", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { + ...creds, + machineId: "", + }); + expect(headers["Cosy-Machineid"]).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("throws when userId is missing", () => { + expect(() => + buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { ...creds, userId: "" }), + ).toThrow(/user id is empty/); + }); + + it("throws when authToken is missing", () => { + expect(() => + buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { ...creds, authToken: "" }), + ).toThrow(/auth token is empty/); + }); + + it("Cosy-User reflects the supplied userId verbatim", () => { + const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds); + expect(headers["Cosy-User"]).toBe("test-user-id"); + }); + + it("two calls with identical inputs differ only in fields that include fresh randomness", () => { + // The signature fingerprints a fresh AES key + UUID per call, so the + // signature, Cosy-Key, X-Request-Id, and Cosy-Date (1s resolution) + // can differ — but Cosy-User, Cosy-Bodyhash, Cosy-Bodylength, + // Cosy-Sigpath, and the machineId-derived headers must be stable. + const a = buildCosyHeaders(Buffer.from("payload", "utf8"), QODER_CHAT_URL_ENCODED, creds); + const b = buildCosyHeaders(Buffer.from("payload", "utf8"), QODER_CHAT_URL_ENCODED, creds); + expect(a["Cosy-User"]).toBe(b["Cosy-User"]); + expect(a["Cosy-Bodyhash"]).toBe(b["Cosy-Bodyhash"]); + expect(a["Cosy-Bodylength"]).toBe(b["Cosy-Bodylength"]); + expect(a["Cosy-Sigpath"]).toBe(b["Cosy-Sigpath"]); + expect(a["Cosy-Machineid"]).toBe(b["Cosy-Machineid"]); + expect(a["X-Request-Id"]).not.toBe(b["X-Request-Id"]); + }); +});