Correctness: - testUtils: drop checkExpiry so the userinfo URL probe actually runs (revoked tokens used to look "active" until local 30-day expiry passed) - auth.parseExpiry: handle numeric expiresAt, swap parseInt before Date.parse so "2026" doesn't get interpreted as year-2026, treat expires_in:0 as already-expired instead of fabricating a 30-day default - providers.mapTokens: synthesize email from userId when fetchUserInfo fails so OAuth dedup works (re-logins no longer accumulate "Account N" rows) SSE wrapper: - wrapQoderSSE: add !doneEmitted guard on success branch (chunks could leak past [DONE] when an error envelope shared a TCP packet with a valid one) - flush(): finalize TextDecoder + drain trailing buffer so the chunk carrying finish_reason is delivered when upstream closes without a final \n - sanitize literal \n inside inner OpenAI body so SSE framing stays intact Robustness: - executor: wrap buildCosyHeaders in try/catch so a missing accessToken returns 401 (re-auth) instead of bubbling as 500 - executor: short-circuit on missing accessToken before signing - executor: plumb proxyOptions/signal through buildQoderRequestBody so proxy-only networks can fetch the model_config catalog - qoderModels: dedupe concurrent first-time misses with an in-flight Promise map (parallel chat windows now do 1 upstream fetch instead of N) - qoderModels: check signal.aborted before addEventListener so a pre-aborted parent signal cancels the inner fetch immediately - auth: AbortController + 15s timeout on pollDeviceToken / fetchUserInfo to prevent hung sockets when openapi.qoder.sh stalls mid-response UX: - OAuthModal: derive polling deadline from device-code expires_in (qoder publishes 300s; the previous fixed 120s caused timeouts when users took more than 2 minutes on the consent page) Cleanup: - delete src/lib/oauth/services/qoder.js — referenced removed config fields (clientId/clientSecret/tokenUrl/authorizeUrl) and was re-exported from services/index.js, so any future caller would TypeError on first use
213 lines
6.8 KiB
JavaScript
213 lines
6.8 KiB
JavaScript
/**
|
|
* 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();
|
|
|
|
/**
|
|
* In-flight fetch promises keyed by cacheKey. Concurrent first-time
|
|
* callers (parallel chat windows) all observe the same Promise so we
|
|
* fan-out exactly one upstream request per credential per miss.
|
|
* @type {Map<string, Promise<{ expiresAt: number, models: any[], rawConfigs: Map<string, object>, fetched: boolean } | null>>}
|
|
*/
|
|
const inflight = 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") {
|
|
// If the parent signal already aborted before we got here, the
|
|
// 'abort' event has already fired and addEventListener won't
|
|
// re-trigger it. Propagate the cancellation immediately.
|
|
if (signal.aborted) {
|
|
controller.abort(signal.reason);
|
|
} else {
|
|
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, and
|
|
* deduplicates concurrent misses so parallel chat windows fan-out exactly
|
|
* one upstream request per credential.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Coalesce concurrent misses on the same credential into one upstream call.
|
|
// forceRefresh callers still get their own fetch (they wanted fresh data).
|
|
const existing = inflight.get(key);
|
|
if (existing && !options.forceRefresh) {
|
|
return existing;
|
|
}
|
|
|
|
const fetchPromise = (async () => {
|
|
const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions);
|
|
if (!fetched) return null;
|
|
const entry = {
|
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
models: fetched.models,
|
|
rawConfigs: fetched.rawConfigs,
|
|
fetched: true,
|
|
};
|
|
catalogCache.set(key, entry);
|
|
return entry;
|
|
})();
|
|
|
|
inflight.set(key, fetchPromise);
|
|
try {
|
|
return await fetchPromise;
|
|
} finally {
|
|
// Clear only if this is still the in-flight entry — a forceRefresh
|
|
// call that started later may have replaced it.
|
|
if (inflight.get(key) === fetchPromise) {
|
|
inflight.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function invalidateQoderCatalog(credentials) {
|
|
if (!credentials) return;
|
|
catalogCache.delete(cacheKey(credentials));
|
|
}
|
|
|
|
export function clearQoderCatalog() {
|
|
catalogCache.clear();
|
|
}
|