mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user