mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat(qoder): port Kiro-style provider integration with COSY signing
Replaces the Qoder placeholder with a real free-tier provider: - Device-flow OAuth: PKCE + nonce generated locally, user authorizes at qoder.com/device/selectAccounts, poll openapi.qoder.sh until token - COSY signing (RSA-1024 + AES-128-CBC + MD5) for chat / model-list - WAF-bypass body encoding (custom-alphabet base64 + thirds rearrange) - Live model_config catalog from /algo/api/v2/model/list, cached 1h - 11 models registered (auto/ultimate/performance/efficient/lite + 6 frontier *model ids) - Usage fetcher for openapi.qoder.sh/api/v2/quota/usage - Dashboard live-models resolver, provider test, OAuth modal hookup - 24 unit tests covering encoder, PKCE, COSY headers, sigPath stripping
This commit is contained in:
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+375
-43
@@ -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 { 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: <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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user