codex-proxy / src /models /model-fetcher.ts
icebear
fix: model list not updating at startup β€” fast-retry on auth race (#149)
56be298 unverified
raw
history blame
5.86 kB
/**
* Model Fetcher β€” background model list refresh from Codex backend.
*
* - Probes known endpoints to discover the models list
* - Normalizes and merges into the model store
* - Non-fatal: all errors log warnings but never crash the server
*/
import { CodexApi } from "../proxy/codex-api.js";
import { applyBackendModelsForPlan } from "./model-store.js";
import type { AccountPool } from "../auth/account-pool.js";
import type { CookieJar } from "../proxy/cookie-jar.js";
import type { ProxyPool } from "../proxy/proxy-pool.js";
import { jitter } from "../utils/jitter.js";
const REFRESH_INTERVAL_HOURS = 1;
const INITIAL_DELAY_MS = 1_000; // 1s after startup (fast plan-map population for mixed-plan routing)
const RETRY_DELAY_MS = 10_000; // 10s retry when accounts aren't ready yet
const MAX_RETRIES = 12; // ~2 minutes of retries before falling back to hourly
let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
let _accountPool: AccountPool | null = null;
let _cookieJar: CookieJar | null = null;
let _proxyPool: ProxyPool | null = null;
let _hasFetchedOnce = false;
/**
* Fetch models from the Codex backend, one query per distinct plan type.
* Returns true if at least one plan's models were fetched successfully.
*/
async function fetchModelsFromBackend(
accountPool: AccountPool,
cookieJar: CookieJar,
proxyPool: ProxyPool | null,
): Promise<boolean> {
if (!accountPool.isAuthenticated()) return false;
const planAccounts = accountPool.getDistinctPlanAccounts();
if (planAccounts.length === 0) {
console.warn("[ModelFetcher] No available accounts β€” skipping model fetch");
return false;
}
console.log(`[ModelFetcher] Fetching models for ${planAccounts.length} plan(s): ${planAccounts.map((p) => p.planType).join(", ")}`);
let anySuccess = false;
const results = await Promise.allSettled(
planAccounts.map(async (pa) => {
try {
const proxyUrl = proxyPool?.resolveProxyUrl(pa.entryId);
const api = new CodexApi(pa.token, pa.accountId, cookieJar, pa.entryId, proxyUrl);
const models = await api.getModels();
if (models && models.length > 0) {
applyBackendModelsForPlan(pa.planType, models);
console.log(`[ModelFetcher] Plan "${pa.planType}": ${models.length} models`);
anySuccess = true;
} else {
console.log(`[ModelFetcher] Plan "${pa.planType}": empty model list β€” keeping existing`);
}
} finally {
accountPool.release(pa.entryId);
}
}),
);
for (const r of results) {
if (r.status === "rejected") {
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
console.warn(`[ModelFetcher] Plan fetch failed: ${msg}`);
}
}
return anySuccess;
}
/**
* Start the background model refresh loop.
* - First fetch after a short delay (auth must be ready)
* - If accounts aren't ready, retry every 10s (up to ~2 min) before falling back to hourly
* - Subsequent fetches every ~1 hour with jitter
*/
export function startModelRefresh(
accountPool: AccountPool,
cookieJar: CookieJar,
proxyPool?: ProxyPool,
): void {
_accountPool = accountPool;
_cookieJar = cookieJar;
_proxyPool = proxyPool ?? null;
_hasFetchedOnce = false;
// Initial fetch after short delay
_refreshTimer = setTimeout(() => {
attemptInitialFetch(accountPool, cookieJar, 0);
}, INITIAL_DELAY_MS);
console.log("[ModelFetcher] Scheduled initial model fetch in 1s");
}
/**
* Attempt initial fetch with fast retry.
* Accounts may still be refreshing tokens at startup (Electron race condition).
* Retry every 10s until success or max retries, then fall back to hourly.
*/
function attemptInitialFetch(
accountPool: AccountPool,
cookieJar: CookieJar,
attempt: number,
): void {
fetchModelsFromBackend(accountPool, cookieJar, _proxyPool)
.then((success) => {
if (success) {
_hasFetchedOnce = true;
scheduleNext(accountPool, cookieJar);
} else if (attempt < MAX_RETRIES) {
console.log(`[ModelFetcher] Accounts not ready, retry ${attempt + 1}/${MAX_RETRIES} in ${RETRY_DELAY_MS / 1000}s`);
_refreshTimer = setTimeout(() => {
attemptInitialFetch(accountPool, cookieJar, attempt + 1);
}, RETRY_DELAY_MS);
} else {
console.warn("[ModelFetcher] Max retries reached, falling back to hourly refresh");
scheduleNext(accountPool, cookieJar);
}
})
.catch(() => {
scheduleNext(accountPool, cookieJar);
});
}
function scheduleNext(
accountPool: AccountPool,
cookieJar: CookieJar,
): void {
const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
_refreshTimer = setTimeout(async () => {
try {
await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
} finally {
scheduleNext(accountPool, cookieJar);
}
}, intervalMs);
}
/**
* Trigger an immediate model refresh (e.g. after hot-reload or account login).
* No-op if startModelRefresh() hasn't been called yet.
*/
export function triggerImmediateRefresh(): void {
if (_accountPool && _cookieJar) {
fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool)
.then((success) => {
if (success) _hasFetchedOnce = true;
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
});
}
}
/** Whether at least one successful backend fetch has completed. */
export function hasFetchedModels(): boolean {
return _hasFetchedOnce;
}
/**
* Stop the background refresh timer.
*/
export function stopModelRefresh(): void {
if (_refreshTimer) {
clearTimeout(_refreshTimer);
_refreshTimer = null;
console.log("[ModelFetcher] Stopped model refresh");
}
}