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:
parent
468c61b2ac
commit
a6fd84691b
20 changed files with 1506 additions and 132 deletions
|
|
@ -144,6 +144,21 @@ export const PROVIDER_MODELS = {
|
||||||
{ id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" },
|
{ 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)" },
|
{ 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
|
cu: [ // Cursor IDE
|
||||||
{ id: "default", name: "Auto (Server Picks)" },
|
{ id: "default", name: "Auto (Server Picks)" },
|
||||||
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
|
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
|
||||||
|
|
@ -870,6 +885,7 @@ const OAUTH_ALIASES = {
|
||||||
kilocode: "kc",
|
kilocode: "kc",
|
||||||
cline: "cl",
|
cline: "cl",
|
||||||
opencode: "oc",
|
opencode: "oc",
|
||||||
|
qoder: "qd",
|
||||||
vertex: "vertex",
|
vertex: "vertex",
|
||||||
"vertex-partner": "vertex-partner",
|
"vertex-partner": "vertex-partner",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,13 @@ export const PROVIDERS = {
|
||||||
authUrl: "https://iflow.cn/oauth"
|
authUrl: "https://iflow.cn/oauth"
|
||||||
},
|
},
|
||||||
qoder: {
|
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",
|
format: "openai",
|
||||||
headers: { "User-Agent": "Qoder-Cli" },
|
headers: {},
|
||||||
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"
|
|
||||||
},
|
},
|
||||||
antigravity: {
|
antigravity: {
|
||||||
baseUrls: [
|
baseUrls: [
|
||||||
|
|
|
||||||
|
|
@ -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/<key>" 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 { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/providers.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
|
* Hoist role:"system" messages out of the messages array (Qoder rejects
|
||||||
* Requires 3 custom headers to avoid 406 error: session-id, x-qoder-timestamp, x-qoder-signature
|
* 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: <inner>\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 {
|
export class QoderExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("qoder", PROVIDERS.qoder);
|
super("qoder", PROVIDERS.qoder);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
buildUrl() {
|
||||||
* Create Qoder signature using HMAC-SHA256
|
return QODER_CHAT_URL_ENCODED;
|
||||||
* 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Override execute entirely — Qoder needs:
|
||||||
* Build headers with Qoder-specific signature
|
// - body built from translated chat completion payload
|
||||||
*/
|
// - body encoded with QoderEncodeBody before signing
|
||||||
buildHeaders(credentials, stream = true) {
|
// - COSY headers built from the *encoded* body bytes
|
||||||
const sessionID = `session-${crypto.randomUUID()}`;
|
// - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE
|
||||||
const timestamp = Date.now();
|
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||||
const userAgent = this.config.headers["User-Agent"] || "Qoder-Cli";
|
const url = this.buildUrl();
|
||||||
const apiKey = credentials.apiKey || credentials.accessToken || "";
|
|
||||||
|
|
||||||
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 = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...this.config.headers,
|
Accept: "text/event-stream",
|
||||||
"session-id": sessionID,
|
"Cache-Control": "no-cache",
|
||||||
"x-qoder-timestamp": timestamp.toString(),
|
"X-Model-Key": qoderKey,
|
||||||
"x-qoder-signature": signature,
|
"X-Model-Source": modelSource,
|
||||||
|
// gzip triggers signature validation on Qoder's CDN; force identity.
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
...cosyHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (credentials.apiKey) {
|
let response;
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
try {
|
||||||
} else if (credentials.accessToken) {
|
response = await proxyAwareFetch(
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
url,
|
||||||
|
{ method: "POST", headers, body: encodedBodyBuf, signal },
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream) {
|
if (!response.ok) {
|
||||||
headers["Accept"] = "text/event-stream";
|
// 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) {
|
// Qoder device tokens don't refresh through OAuth — the upstream returns
|
||||||
return this.config.baseUrl;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
needsRefresh() {
|
||||||
* Inject stream_options for usage data on streaming requests
|
return false;
|
||||||
*/
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
|
||||||
if (stream && body.messages && !body.stream_options) {
|
|
||||||
body.stream_options = { include_usage: true };
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ const ALIAS_TO_PROVIDER_ID = {
|
||||||
cl: "cline",
|
cl: "cline",
|
||||||
oc: "opencode",
|
oc: "opencode",
|
||||||
ocg: "opencode-go",
|
ocg: "opencode-go",
|
||||||
|
qd: "qoder",
|
||||||
|
qoder: "qoder",
|
||||||
// TTS providers
|
// TTS providers
|
||||||
el: "elevenlabs",
|
el: "elevenlabs",
|
||||||
// API Key providers
|
// API Key providers
|
||||||
|
|
|
||||||
176
open-sse/services/qoderModels.js
Normal file
176
open-sse/services/qoderModels.js
Normal file
|
|
@ -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<string, { expiresAt: number, models: any[], rawConfigs: Map<string, object>, 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<modelKey, modelConfigObject> }
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,8 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||||
return await getCodexUsage(accessToken, proxyOptions);
|
return await getCodexUsage(accessToken, proxyOptions);
|
||||||
case "kiro":
|
case "kiro":
|
||||||
return await getKiroUsage(accessToken, providerSpecificData, proxyOptions);
|
return await getKiroUsage(accessToken, providerSpecificData, proxyOptions);
|
||||||
|
case "qoder":
|
||||||
|
return await getQoderUsage(accessToken, proxyOptions);
|
||||||
case "qwen":
|
case "qwen":
|
||||||
return await getQwenUsage(accessToken, providerSpecificData);
|
return await getQwenUsage(accessToken, providerSpecificData);
|
||||||
case "iflow":
|
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." };
|
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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
public/providers/qoder.png
Normal file
BIN
public/providers/qoder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7 KiB |
|
|
@ -151,7 +151,7 @@ export async function GET(request, { params }) {
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Providers that don't use PKCE for device code
|
// 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;
|
let deviceData;
|
||||||
if (noPkceDeviceProviders.includes(provider)) {
|
if (noPkceDeviceProviders.includes(provider)) {
|
||||||
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
|
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
|
||||||
|
|
@ -162,7 +162,9 @@ export async function GET(request, { params }) {
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...deviceData,
|
...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") {
|
} else if (provider === "kiro") {
|
||||||
// Kiro needs extraData (clientId, clientSecret) from device code response
|
// Kiro needs extraData (clientId, clientSecret) from device code response
|
||||||
result = await pollForToken(provider, deviceCode, null, extraData);
|
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 {
|
} else {
|
||||||
// Qwen and other PKCE providers
|
// Qwen and other PKCE providers
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||||
import { resolveKiroModels } from "open-sse/services/kiroModels.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";
|
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||||
|
|
||||||
|
|
@ -286,6 +287,41 @@ const PROVIDER_MODELS_CONFIG = {
|
||||||
return { models: [], warning };
|
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": {
|
"gemini-cli": {
|
||||||
customResolver: buildOAuthResolver({
|
customResolver: buildOAuthResolver({
|
||||||
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),
|
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,15 @@ const OAUTH_TEST_CONFIG = {
|
||||||
},
|
},
|
||||||
qwen: { checkExpiry: true, refreshable: true },
|
qwen: { checkExpiry: true, refreshable: true },
|
||||||
kiro: { 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 },
|
"kimi-coding": { checkExpiry: true, refreshable: false },
|
||||||
cursor: { tokenExists: true },
|
cursor: { tokenExists: true },
|
||||||
kilocode: {
|
kilocode: {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
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
|
// Per-provider live model resolvers. Each receives a connection record and
|
||||||
// returns { models: [{ id, name? }, ...] } | null on failure.
|
// returns { models: [{ id, name? }, ...] } | null on failure.
|
||||||
|
|
@ -20,6 +21,19 @@ const LIVE_MODEL_RESOLVERS = {
|
||||||
providerSpecificData: conn.providerSpecificData || {}
|
providerSpecificData: conn.providerSpecificData || {}
|
||||||
}, { log: console });
|
}, { log: console });
|
||||||
return result?.models?.length ? { models: result.models } : null;
|
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",
|
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 = {
|
export const QODER_CONFIG = {
|
||||||
apiBaseUrl: "https://api2.qoder.sh",
|
openApiBaseUrl: "https://openapi.qoder.sh",
|
||||||
deviceTokenUrl: "https://api2.qoder.sh/api/v1/deviceToken/poll",
|
centerBaseUrl: "https://center.qoder.sh",
|
||||||
deviceRefreshUrl: "https://api2.qoder.sh/api/v1/deviceToken/refresh",
|
chatBaseUrl: "https://api3.qoder.sh",
|
||||||
refreshUrl: "https://api2.qoder.sh/api/v3/user/refresh_token",
|
deviceTokenUrl: "https://openapi.qoder.sh/api/v1/deviceToken/poll",
|
||||||
userInfoUrl: "https://api2.qoder.sh/api/v1/userinfo",
|
refreshUrl: "https://center.qoder.sh/algo/api/v3/user/refresh_token",
|
||||||
statusUrl: "https://api2.qoder.sh/api/v3/user/status",
|
userInfoUrl: "https://openapi.qoder.sh/api/v1/userinfo",
|
||||||
loginUrl: "https://qoder.com/login",
|
quotaUsageUrl: "https://openapi.qoder.sh/api/v2/quota/usage",
|
||||||
|
loginUrl: "https://qoder.com/device/selectAccounts",
|
||||||
};
|
};
|
||||||
|
|
||||||
// iFlow OAuth Configuration (Authorization Code)
|
// iFlow OAuth Configuration (Authorization Code)
|
||||||
|
|
|
||||||
|
|
@ -600,80 +600,93 @@ const PROVIDERS = {
|
||||||
|
|
||||||
qoder: {
|
qoder: {
|
||||||
config: QODER_CONFIG,
|
config: QODER_CONFIG,
|
||||||
flowType: "authorization_code",
|
flowType: "device_code",
|
||||||
buildAuthUrl: (config, redirectUri, state) => {
|
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
|
||||||
const params = new URLSearchParams({
|
// locally, the user lands on qoder.com/device/selectAccounts in the
|
||||||
client_id: config.clientId,
|
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
|
||||||
response_type: "code",
|
requestDeviceCode: async (config) => {
|
||||||
redirect_uri: redirectUri,
|
const { initiateDeviceFlow } = await import("@/lib/qoder/auth");
|
||||||
state: state,
|
const flow = initiateDeviceFlow();
|
||||||
});
|
// Match the device_code shape the rest of the OAuthModal expects
|
||||||
return `${config.authorizeUrl}?${params.toString()}`;
|
// (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) => {
|
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||||
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
const { pollDeviceToken, fetchUserInfo } = await import("@/lib/qoder/auth");
|
||||||
|
const nonce = deviceCode || extraData?._qoderNonce;
|
||||||
const response = await fetch(config.tokenUrl, {
|
const verifier = codeVerifier || extraData?._qoderVerifier;
|
||||||
method: "POST",
|
if (!nonce || !verifier) {
|
||||||
headers: {
|
return {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
ok: false,
|
||||||
Accept: "application/json",
|
data: { error: "invalid_request", error_description: "Missing nonce/verifier" },
|
||||||
Authorization: `Basic ${basicAuth}`,
|
};
|
||||||
|
}
|
||||||
|
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) => {
|
mapTokens: (tokens) => {
|
||||||
// Fetch user info (MUST succeed to get API key)
|
const email = (tokens._qoderEmail || "").trim() || null;
|
||||||
const userInfoRes = await fetch(
|
const displayName = (tokens._qoderName || "").trim() || null;
|
||||||
`${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
return {
|
||||||
{ headers: { Accept: "application/json" } }
|
accessToken: tokens.access_token,
|
||||||
);
|
refreshToken: tokens.refresh_token || null,
|
||||||
|
expiresIn: tokens.expires_in,
|
||||||
if (!userInfoRes.ok) {
|
email,
|
||||||
const errorText = await userInfoRes.text();
|
displayName,
|
||||||
throw new Error(`Failed to fetch user info: ${errorText}`);
|
providerSpecificData: {
|
||||||
}
|
authMethod: "device",
|
||||||
|
userId: tokens._qoderUserId || "",
|
||||||
const result = await userInfoRes.json();
|
machineId: tokens._qoderMachineId || "",
|
||||||
if (!result.success) {
|
organizationId: tokens._qoderOrganizationId || "",
|
||||||
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, 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: {
|
qwen: {
|
||||||
|
|
|
||||||
172
src/lib/qoder/auth.js
Normal file
172
src/lib/qoder/auth.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
63
src/lib/qoder/constants.js
Normal file
63
src/lib/qoder/constants.js
Normal file
|
|
@ -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-----`;
|
||||||
175
src/lib/qoder/cosy.js
Normal file
175
src/lib/qoder/cosy.js
Normal file
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
55
src/lib/qoder/encoding.js
Normal file
55
src/lib/qoder/encoding.js
Normal file
|
|
@ -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);
|
setError(null);
|
||||||
|
|
||||||
// Device code flow providers
|
// 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)) {
|
if (deviceCodeProviders.includes(provider)) {
|
||||||
setIsDeviceCode(true);
|
setIsDeviceCode(true);
|
||||||
setStep("waiting");
|
setStep("waiting");
|
||||||
|
|
@ -175,7 +175,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||||
if (verifyUrl) window.open(verifyUrl, "_blank", "noopener,noreferrer");
|
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"
|
const extraData = provider === "kiro"
|
||||||
? {
|
? {
|
||||||
_clientId: data._clientId,
|
_clientId: data._clientId,
|
||||||
|
|
@ -184,6 +186,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
_authMethod: data._authMethod,
|
_authMethod: data._authMethod,
|
||||||
_startUrl: data._startUrl,
|
_startUrl: data._startUrl,
|
||||||
}
|
}
|
||||||
|
: provider === "qoder"
|
||||||
|
? {
|
||||||
|
_qoderNonce: data._qoderNonce,
|
||||||
|
_qoderMachineId: data._qoderMachineId,
|
||||||
|
_qoderVerifier: data.codeVerifier,
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
||||||
return;
|
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" } },
|
"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" },
|
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
// 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" } },
|
// 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" } },
|
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" } },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
238
tests/unit/qoder.test.js
Normal file
238
tests/unit/qoder.test.js
Normal file
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue