File size: 7,147 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | /**
* 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;
// Always cache the config — chat needs model_config even for UI-hidden
// models (enable:false). Upstream still accepts chat for these keys.
rawConfigs.set(key, entry);
if (entry.enable === false) continue;
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();
}
|