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:
Simon Shi
2026-05-29 17:36:27 +07:00
committed by decolua
parent 468c61b2ac
commit a6fd84691b
20 changed files with 1506 additions and 132 deletions
+176
View 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();
}