// Text model discovery for the n8n ChatGPT sidecar. It lists the account's models from the // SIWC catalog, asking as the newest stable Codex release on npm (checked at most every // 12 h), and hides models OpenAI rejected. With the opt-in model checks it lists only models // that answered one tiny request. It also counts the sidecar's own text requests per UTC day // and model for the usage dashboard. Built-ins only, except the CLI entry's SIWC session // import: this file ships inside the sidecar image. import * as fs from 'node:fs/promises'; import { constants } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { setTimeout as pause } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; // Pins the local Codex installs (local-endpoints.js). The catalog is never asked as an older client. export const CODEX_CLI_VERSION = '0.160.0'; // The model-check disclosure the owner approves. Changing what checks send needs a new version. export const MODEL_CHECKS_NOTICE = 'model-checks-2026-10-06'; export const MODEL_CHECKS_DIR = 'model-checks'; export const CATALOG_TTL_MS = 300_000; export const VERSION_TTL_MS = 43_200_000; export const FAILURE_RETRY_MS = 86_400_000; export const ACTIVITY_DIR = 'activity'; // Request counts are written at most this often, plus once when the sidecar stops. export const ACTIVITY_WRITE_MS = 30_000; const VERSION_URL = 'https://registry.npmjs.org/@openai/codex/latest'; const MODELS_URL = 'https://api.openai.com/v1/models'; const RESPONSES_URL = 'https://api.openai.com/v1/responses'; const VERSION_TIMEOUT_MS = 5_000; const CATALOG_TIMEOUT_MS = 20_000; const PROBE_TIMEOUT_MS = 60_000; const STALE_CATALOG_MS = 3_600_000; const PAUSE_MS = 3_600_000; // The installer runs the CLI in a remote exec that holds the VPS operation lock. const CHECKS_ON_DEADLINE_MS = 180_000; const STATUS_DEADLINE_MS = 60_000; // Part of the checks-on deadline kept for saving the setting after the probes. const SAVE_RESERVE_MS = 15_000; const MAX_VERSION_BODY = 1024 * 1024; const MAX_CATALOG_BODY = 4 * 1024 * 1024; const MAX_STREAM = 256 * 1024; const MAX_FILE = 64 * 1024; const MAX_MODELS = 256; const MAX_ROWS = 64; const MAX_LABEL = 256; const LOCK_STALE_MS = 120_000; const LOCK_RETRY_MS = 100; const LOCK_ATTEMPTS = 450; const STABLE_VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/u; const SLUG = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u; const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; const CODE = /^[A-Za-z0-9_.-]{1,64}$/u; const TOKEN = /^[!-~]{1,16384}$/u; const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; const TERMINAL_FAILURES = new Set(['response.failed', 'response.incomplete', 'error']); const USAGE_CODES = new Set(['subscription_sharing_usage_limit_exceeded', 'subscription_sharing_usage_unavailable', 'subscription_sharing_user_unavailable']); const ACTIVITY_DAYS = 31; const USAGE_DAYS = 30; const ACTIVITY_MODELS = 64; const OTHER_MODEL = 'other'; // 31 days of 64 named models plus "other", with 128-character IDs, stay under 800 KB. The cap // keeps the usage command's output under the 1 MB limit of the SSH and Docker readers. const MAX_ACTIVITY_FILE = 900 * 1024; const ACTIVITY_LOCK_MS = 5_000; const DAY_MS = 86_400_000; const DAY = /^\d{4}-\d{2}-\d{2}$/u; const COUNTERS = ['requests', 'completed', 'failed', 'incomplete', 'input', 'cached', 'output', 'reasoning', 'total']; const ACTIVITY_FIELDS = ['schemaVersion', 'registrationId', 'startedAt', 'updatedAt', 'days', 'lastUsageEvent']; // OpenAI's plan-usage error codes and the recovery each one documents. No reset time is derived from them. const USAGE_EVENTS = Object.freeze({ subscription_sharing_usage_limit_exceeded: 'manage-usage', subscription_sharing_usage_unavailable: 'retry-later', subscription_sharing_user_unavailable: 'retry-later', subscription_sharing_user_not_eligible: 'none', }); const CLI_ERRORS = new Set(['invalid_command', 'invalid_configuration', 'store_unsafe', 'store_busy']); const uid = process.getuid?.(); class ModelDiscoveryError extends Error { constructor(code, message, details) { super(message); this.code = code; Object.assign(this, details); } } // Messages are fixed text, so no token or upstream text can reach them. const UNSAFE = 'The model-check record is unsafe. An administrator must inspect it.'; const storeUnsafe = () => new ModelDiscoveryError('store_unsafe', UNSAFE); // A safe file that does not parse. Only turning checks off may replace it. const storeCorrupt = () => new ModelDiscoveryError('store_unsafe', UNSAFE, { corrupt: true }); const storeBusy = () => new ModelDiscoveryError('store_busy', 'The model-check record is busy. Try again.'); const activityUnsafe = () => new ModelDiscoveryError('store_unsafe', 'The request count record is unsafe. An administrator must inspect it.'); const catalogUnavailable = (status) => new ModelDiscoveryError('catalog_unavailable', 'The account model catalog is unavailable.', { status, recovery: status === 401 ? 'reauthorize' : 'retry-later' }); const invalidConfiguration = () => new ModelDiscoveryError('invalid_configuration', 'The sidecar configuration is invalid.'); const catalogErrorOf = (error) => error instanceof ModelDiscoveryError && error.code === 'catalog_unavailable' ? 'catalog_unavailable' : 'registration_unavailable'; const settings = (deps) => ({ fetchImpl: deps?.fetchImpl ?? fetch, now: deps?.now ?? Date.now, fileSystem: deps?.fileSystem ?? fs, randomUUID: deps?.randomUUID ?? randomUUID, sleep: deps?.sleep ?? pause, setTimer: deps?.setTimer ?? setTimeout, clearTimer: deps?.clearTimer ?? clearTimeout, }); const iso = (ms) => new Date(ms).toISOString(); const isIso = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && iso(Date.parse(value)) === value; const matches = (value, pattern) => typeof value === 'string' && pattern.test(value); const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const validBudget = (value) => value === undefined || Number.isSafeInteger(value) && value > 0; const ageOf = (at, since) => Math.abs(at - since); const versionRank = (version) => matches(version, STABLE_VERSION) ? version.split('.').reduce((rank, part) => rank * 10_000 + Number(part), 0) : -1; const newerVersion = (version, pinned) => versionRank(version) > versionRank(pinned) ? version : pinned; const validRecord = (record) => isObject(record) && (record.state === 'verified' || record.state === 'failed') && isIso(record.checkedAt) && (record.source === 'probe' || record.source === 'traffic') && (record.code === undefined || matches(record.code, CODE)); const validConsent = (value) => isObject(value) && isIso(value.acceptedAt) && matches(value.noticeVersion, CODE); const validRelease = (value) => isObject(value) && isIso(value.checkedAt) && (value.version === undefined || matches(value.version, STABLE_VERSION)); const recent = (record, at) => ageOf(at, Date.parse(record.checkedAt)) < FAILURE_RETRY_MS; // A traffic failure hides a model in every mode, a probe failure only while checks are on. const hides = (record, on, at) => record?.state === 'failed' && (record.source === 'traffic' || on) && recent(record, at); const due = (record, at) => !record || record.state === 'failed' && !recent(record, at); // What passive learning skips: any verified record, or a traffic failure that still hides the model. const settled = (record, state, at) => state === 'verified' ? record?.state === 'verified' : record?.state === 'failed' && record.source === 'traffic' && recent(record, at); // Checks count as on only while the current disclosure is approved. const active = (store) => store.checksEnabled && store.consent?.noticeVersion === MODEL_CHECKS_NOTICE; const emptyStore = () => ({ checksEnabled: false, consent: null, models: new Map() }); const withModel = (store, slug, record) => ({ ...store, models: new Map(store.models).set(slug, record) }); // The models n8n gets: with checks on only verified ones, or until one is verified, all that no failure hides. function listedEntries(entries, store, at) { const on = active(store); const visible = entries.filter(({ id }) => !hides(store.models.get(id), on, at)); if (!on) return visible; const verified = entries.filter(({ id }) => store.models.get(id)?.state === 'verified'); return verified.length ? verified : visible; } // A caller that gives up stops waiting; the work it waited on carries on. function waitFor(promise, signal) { if (!signal) return promise; return new Promise((done, fail) => { const abort = () => fail(signal.reason); if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); promise.then(done, fail).finally(() => signal.removeEventListener('abort', abort)); }); } // --- Request counts --- // Per UTC day and model: the text requests the sidecar sent to OpenAI, how each ended, and the // token counts of completed ones. No prompt, output, request ID, address or header is kept. const dayOf = (ms) => iso(ms).slice(0, 10); const isDay = (value) => matches(value, DAY) && isIso(`${value}T00:00:00.000Z`); const whole = (value) => Number.isSafeInteger(value) && value >= 0; // Sums stop at the largest safe integer instead of losing precision. const plus = (a, b) => Math.min(Number.MAX_SAFE_INTEGER, a + b); const zeroCounts = () => Object.fromEntries(COUNTERS.map((key) => [key, 0])); function addCounts(target, source) { for (const key of COUNTERS) target[key] = plus(target[key], source[key] ?? 0); return target; } // One model's counters for one day. A day names at most 64 models; later ones count as "other". function dayCounts(days, day, id) { const models = days.get(day) ?? days.set(day, new Map()).get(day); const named = [...models.keys()].filter((key) => key !== OTHER_MODEL).length; const key = models.has(id) || id === OTHER_MODEL || named < ACTIVITY_MODELS ? id : OTHER_MODEL; return models.get(key) ?? models.set(key, zeroCounts()).get(key); } function trimDays(days) { for (const day of [...days.keys()].sort().slice(0, -ACTIVITY_DAYS)) days.delete(day); } // Whole token counts from a completed response's usage; anything missing or invalid counts as zero. function tokenCounts(usage) { const count = (value) => whole(value) ? value : 0; const input = count(usage?.input_tokens); const output = count(usage?.output_tokens); return { input, cached: count(usage?.input_tokens_details?.cached_tokens), output, reasoning: count(usage?.output_tokens_details?.reasoning_tokens), total: whole(usage?.total_tokens) ? usage.total_tokens : plus(input, output) }; } const validCounts = (value) => isObject(value) && Object.keys(value).length === COUNTERS.length && COUNTERS.every((key) => whole(value[key])); const validUsageEvent = (value) => isObject(value) && Object.keys(value).length === 2 && isIso(value.at) && Object.hasOwn(USAGE_EVENTS, value.code); // The stored record, checked field by field. Anything else, an extra field included, is corrupt. function parseActivity(value, registrationId) { const days = isObject(value?.days) ? Object.entries(value.days) : []; if (!isObject(value) || Object.keys(value).length !== ACTIVITY_FIELDS.length || !ACTIVITY_FIELDS.every((key) => Object.hasOwn(value, key)) || value.schemaVersion !== 1 || !matches(value.registrationId, ID) || registrationId !== undefined && value.registrationId !== registrationId || !isIso(value.startedAt) || !isIso(value.updatedAt) || !isObject(value.days) || days.length > ACTIVITY_DAYS || !(value.lastUsageEvent === null || validUsageEvent(value.lastUsageEvent)) || !days.every(([day, models]) => isDay(day) && isObject(models) && Object.keys(models).filter((id) => id !== OTHER_MODEL).length <= ACTIVITY_MODELS && Object.entries(models).every(([id, counts]) => matches(id, SLUG) && validCounts(counts)))) throw storeCorrupt(); const event = value.lastUsageEvent; return { registrationId: value.registrationId, startedAt: value.startedAt, updatedAt: value.updatedAt, lastUsageEvent: event && { at: event.at, code: event.code }, days: new Map(days.map(([day, models]) => [day, new Map(Object.entries(models).map(([id, counts]) => [id, addCounts(zeroCounts(), counts)]))])) }; } // The record as stored and as the usage command prints it: days in date order, counters in a fixed order. function activityRecord(store) { return { schemaVersion: 1, registrationId: store.registrationId, startedAt: store.startedAt, updatedAt: store.updatedAt, days: Object.fromEntries([...store.days.keys()].sort().map((day) => [day, Object.fromEntries(store.days.get(day))])), lastUsageEvent: store.lastUsageEvent }; } // Counts not yet written. event: undefined (no change), null (cleared by a completion at clearedAt) or the newest event. const newTally = () => ({ days: new Map(), event: undefined, clearedAt: -Infinity, firstAt: undefined }); // Pending counts added to the stored ones at time `at`; days before the 31-day window are dropped. // The newer plan-usage event wins, and a completion clears an event at or before it. function mergeActivity(stored, tally, at, registrationId) { const days = new Map(); for (const source of [stored?.days, tally.days]) { for (const [day, models] of source ?? []) for (const [id, counts] of models) addCounts(dayCounts(days, day, id), counts); } const oldest = dayOf(at - (ACTIVITY_DAYS - 1) * DAY_MS); for (const day of days.keys()) if (day < oldest) days.delete(day); trimDays(days); let event = stored?.lastUsageEvent ?? null; if (tally.event) event = event && event.at > tally.event.at ? event : tally.event; else if (tally.event === null && event && Date.parse(event.at) <= tally.clearedAt) event = null; return { registrationId, startedAt: stored?.startedAt ?? iso(tally.firstAt), updatedAt: iso(at), days, lastUsageEvent: event }; } // A batch that could not be written goes back in front of the counts that arrived meanwhile. function requeue(batch, newer) { for (const [day, models] of newer.days) for (const [id, counts] of models) addCounts(dayCounts(batch.days, day, id), counts); trimDays(batch.days); return { days: batch.days, event: newer.event === undefined ? batch.event : newer.event, clearedAt: Math.max(batch.clearedAt, newer.clearedAt), firstAt: batch.firstAt ?? newer.firstAt }; } const blankUsage = (state) => ({ state, since: null, updatedAt: null, totals: zeroCounts(), activeDays: 0, peakDay: null, days: [], models: [], lastUsageEvent: null }); // UsageView for the dashboard: the requests this sidecar sent to OpenAI in the last 30 UTC days. These // are Relmio's own counts, not plan use. `record` is what the usage command printed: null before the // first request is "empty", and anything that is not a valid record is "unavailable" and shows none of it. export function usageView(record, { registrationId, now = Date.now() } = {}) { if (record === null) return blankUsage('empty'); let store; try { store = parseActivity(record, registrationId); } catch { return blankUsage('unavailable'); } const first = dayOf(now - (USAGE_DAYS - 1) * DAY_MS); const today = dayOf(now); const totals = zeroCounts(); const byModel = new Map(); const days = []; for (const day of [...store.days.keys()].sort()) { if (day < first || day > today) continue; const sum = zeroCounts(); for (const [id, counts] of store.days.get(day)) { addCounts(sum, counts); addCounts(byModel.get(id) ?? byModel.set(id, zeroCounts()).get(id), counts); } addCounts(totals, sum); days.push({ date: day, requests: sum.requests, total: sum.total }); } // The 64 models with the most tokens keep their rows; the rest join "other", listed last. const other = byModel.get(OTHER_MODEL) ?? zeroCounts(); const ranked = [...byModel].filter(([id]) => id !== OTHER_MODEL) .sort(([a, x], [b, y]) => y.total - x.total || y.requests - x.requests || (a < b ? -1 : 1)); for (const [, counts] of ranked.slice(ACTIVITY_MODELS)) addCounts(other, counts); const rows = [...ranked.slice(0, ACTIVITY_MODELS), ...(other.requests ? [[OTHER_MODEL, other]] : [])]; const peak = days.reduce((best, day) => (day.total > (best?.total ?? 0) ? day : best), null); const windowStart = `${first}T00:00:00.000Z`; return { state: days.length ? 'ok' : 'empty', since: store.startedAt > windowStart ? store.startedAt : windowStart, updatedAt: store.updatedAt, totals, activeDays: days.length, peakDay: peak && { date: peak.date, total: peak.total }, days, models: rows.map(([id, { requests, total, input, cached, output, reasoning }]) => ({ id, requests, total, input, cached, output, reasoning })), lastUsageEvent: store.lastUsageEvent && { ...store.lastUsageEvent, recovery: USAGE_EVENTS[store.lastUsageEvent.code] }, }; } // Reads one run of the usage command: the record, null when there is nothing to show yet, or undefined // when it cannot be read. A sidecar built before request counting has no usage command, or no model module // at all, and counts nothing, so it reads as empty until its update. Any other failed run, a failed exec, // a restarting container or a crash, is unavailable. export function usageRecordFromCli(result, registrationId) { let output; try { output = JSON.parse(result?.stdout); } catch { /* Not one JSON value. */ } if (result?.code === 0) return output === null || output?.registrationId === registrationId ? output : undefined; const older = output?.error === 'invalid_command' || (result?.stdout === '' && /Cannot find module '\/app\/services\/model-discovery\.mjs'|ERR_MODULE_NOT_FOUND/u.test(result?.stderr ?? '')); return older ? null : undefined; } // --- Store files --- // ponytail: these copy the store helpers in codex-images.mjs (storeDirectory, here ownedDirectory, assertOwned, // readRecord, writeRecord, lockAge, locked). A safety fix in either file belongs in both. Differences: readRecord here // tells a safe file that does not parse from an unsafe one, locked can stop waiting at a deadline, and the folder // name and size limit are parameters so the model-check and request count records share them. function assertOwned(stat, kind) { if (stat.isSymbolicLink() || (kind === 'file' ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory()) || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) throw storeUnsafe(); return stat; } // ${storageRoot}/${name}, created 0700 when asked. null when it does not exist and create is false. async function ownedDirectory(cfg, storageRoot, registrationId, name, create) { if (typeof storageRoot !== 'string' || !isAbsolute(storageRoot) || storageRoot.includes('\0') || resolve(storageRoot) !== storageRoot || !matches(registrationId, ID)) throw storeUnsafe(); const directory = join(storageRoot, name); try { if (create) { try { await cfg.fileSystem.mkdir(directory, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; } } assertOwned(await cfg.fileSystem.lstat(directory), 'directory'); } catch (error) { if (!create && error?.code === 'ENOENT') return null; throw storeUnsafe(); } return directory; } async function readRecord(path, cfg, limit = MAX_FILE) { let stat; try { stat = await cfg.fileSystem.lstat(path); } catch (error) { if (error?.code === 'ENOENT') return null; throw storeUnsafe(); } assertOwned(stat, 'file'); if (stat.size === 0 || stat.size > limit) throw storeCorrupt(); let bytes; try { const handle = await cfg.fileSystem.open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const opened = await handle.stat(); if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw storeUnsafe(); bytes = await handle.readFile(); } finally { await handle.close(); } } catch { throw storeUnsafe(); } try { if (bytes.length > limit) throw new Error('Oversized record.'); return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); } catch { throw storeCorrupt(); } } async function writeRecord(path, bytes, cfg, limit = MAX_FILE) { if (bytes.length > limit) throw storeUnsafe(); const temp = `${path}.${cfg.randomUUID()}.tmp`; try { const handle = await cfg.fileSystem.open(temp, 'wx', 0o600); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } await cfg.fileSystem.rename(temp, path); const directory = await cfg.fileSystem.open(dirname(path), 'r'); try { await directory.sync(); } finally { await directory.close(); } } catch { throw storeUnsafe(); } finally { await cfg.fileSystem.rm(temp, { force: true }).catch(() => {}); } } async function lockAge(path, cfg) { let stat; try { stat = await cfg.fileSystem.lstat(path); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; } let createdAt = Number.NaN; if (stat.isFile() && stat.size <= 1024) { try { createdAt = Date.parse(JSON.parse(await cfg.fileSystem.readFile(path, 'utf8')).createdAt); } catch { /* A holder that crashed before writing falls back to the file time. */ } } if (!Number.isFinite(createdAt)) createdAt = stat.isFile() ? stat.mtimeMs : 0; return { ino: stat.ino, age: Math.abs(cfg.now() - createdAt) }; } async function locked(path, cfg, operation, { wait = true, deadline = Infinity } = {}) { let ino; for (let attempt = 0; ino === undefined; attempt++) { if (attempt > LOCK_ATTEMPTS) throw storeBusy(); let handle; try { handle = await cfg.fileSystem.open(path, 'wx', 0o600); } catch (error) { if (error?.code !== 'EEXIST') throw storeUnsafe(); const held = await lockAge(path, cfg).catch(() => { throw storeUnsafe(); }); if (held && held.age > LOCK_STALE_MS) { // ponytail: lstat-then-unlink can race a second stale taker by microseconds; stale locks only follow a crash. if ((await cfg.fileSystem.lstat(path).catch(() => null))?.ino === held.ino) await cfg.fileSystem.rm(path, { force: true }).catch(() => { throw storeUnsafe(); }); } else if (held) { if (!wait || cfg.now() >= deadline) throw storeBusy(); await cfg.sleep(LOCK_RETRY_MS); } continue; } try { await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: iso(cfg.now()) })); ino = (await handle.stat()).ino; } catch { await cfg.fileSystem.rm(path, { force: true }).catch(() => {}); throw storeUnsafe(); } finally { await handle.close(); } } try { return await operation(); } finally { // A lock replaced as stale belongs to its new holder. if ((await cfg.fileSystem.lstat(path).catch(() => null))?.ino === ino) await cfg.fileSystem.rm(path, { force: true }).catch(() => {}); } } // --- OpenAI and npm responses --- async function readJson(response, limit) { const reader = response.body?.getReader(); if (!reader) throw new Error('Empty response.'); const chunks = []; let total = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > limit) { await reader.cancel(); throw new Error('Oversized response.'); } chunks.push(value); } } finally { reader.releaseLock(); } return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total))); } // A terminal event's outcome, or null to keep reading. function eventOutcome(raw) { let event; try { event = JSON.parse(raw); } catch { return null; } if (event?.type === 'response.completed') return event.response?.status === undefined || event.response.status === 'completed' ? { state: 'verified' } : {}; if (!TERMINAL_FAILURES.has(event?.type)) return null; const code = event.response?.error?.code ?? event.error?.code ?? event.code; if (USAGE_CODES.has(code)) return { stop: 'usage_limit' }; if (code === 'subscription_sharing_invalid_user') return { stop: 'reauthorize' }; return {}; } // Reads the probe's SSE stream to its terminal event, then ends it. async function streamOutcome(response) { const reader = response.body?.getReader(); if (!reader) return {}; const decoder = new TextDecoder(); let pending = ''; let data = []; let total = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) return {}; total += value.byteLength; if (total > MAX_STREAM) return {}; pending += decoder.decode(value, { stream: true }); for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) { const line = pending.slice(0, end).replace(/\r$/u, ''); pending = pending.slice(end + 1); if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); else if (!line && data.length) { const outcome = eventOutcome(data.join('\n')); data = []; if (outcome) return outcome; } } } } finally { await reader.cancel().catch(() => {}); } } function lowestEffort(levels) { const listed = new Set(Array.isArray(levels) ? levels.map((level) => typeof level === 'string' ? level : level?.effort) : []); return EFFORTS.find((effort) => listed.has(effort)); } function catalogEntries(body, token) { if (!Array.isArray(body?.models)) throw new Error('Invalid catalog.'); const leaks = (text) => token.length >= 8 && text.includes(token); const ids = new Set(); const entries = []; for (const model of body.models) { const id = model?.slug; if (!matches(id, SLUG) || /image/iu.test(id) || model.visibility !== 'list' || model.supported_in_api === false || ids.has(id) || leaks(id)) continue; const name = model.display_name; const label = typeof name === 'string' && name.length >= 1 && name.length <= MAX_LABEL && !/\p{C}/u.test(name) && !leaks(name) ? name : id; ids.add(id); entries.push({ id, display_name: label, effort: lowestEffort(model.supported_reasoning_levels) }); if (entries.length === MAX_MODELS) break; } return entries; } // --- Public operations --- // A 400 or 404 counts against the model only when OpenAI blames the model. Returns OpenAI's error // code, "model_rejected" when it gives no usable code, or null. export function classifyModelRejection(status, errorBody, { source } = {}) { if (status !== 400 && status !== 404) return null; const error = isObject(errorBody?.error) ? errorBody.error : errorBody; if (!isObject(error)) return null; const { code, param } = error; // A probe sends nothing else that a bare capability error could refer to; traffic can. const modelLevel = param === 'model' || code === 'model_not_found' || code === 'invalid_model' || source === 'probe' && code === 'subscription_sharing_unsupported_capability' && (param === undefined || param === null); if (!modelLevel) return null; return matches(code, CODE) ? code : 'model_rejected'; } export async function resolveCatalogClientVersion({ pinned, cache, fetchImpl = fetch, now = Date.now } = {}) { cache ??= {}; const at = now(); if (!(ageOf(at, cache.fetchedAt) < VERSION_TTL_MS)) { // A failed check counts too, so npm sees at most one request per 12 h, as disclosed. cache.fetchedAt = at; try { const response = await fetchImpl(VERSION_URL, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(VERSION_TIMEOUT_MS), }); if (response.ok) { const { version } = await readJson(response, MAX_VERSION_BODY); if (matches(version, STABLE_VERSION)) cache.version = version; } else await response.body?.cancel(); } catch { /* The last good version, or the pin, stays in use. */ } } return newerVersion(cache.version, pinned); } export function createModelDiscovery({ storageRoot, registrationId, pinnedClientVersion, getLease, deps } = {}) { if (!matches(pinnedClientVersion, STABLE_VERSION) || typeof getLease !== 'function') throw new TypeError('Model discovery needs a stable pinned client version and a lease source.'); const cfg = settings(deps); const versionCache = {}; let catalog = null; let refreshing = null; let background = null; let pausedAt = -Infinity; // close() aborts this: leases, the catalog request, probes and the spacing sleep end, and runs stop. const closing = new AbortController(); // Last known state per model, so steady-state traffic writes nothing. let known = new Map(); // --- Store: ${storageRoot}/model-checks/${registrationId}.{json,lock} --- const storeDirectory = (create = false) => ownedDirectory(cfg, storageRoot, registrationId, MODEL_CHECKS_DIR, create); const storePath = (directory) => join(directory, `${registrationId}.json`); function parseStore(value) { const entries = isObject(value?.models) ? Object.entries(value.models) : []; if (!isObject(value) || value.schemaVersion !== 1 || value.registrationId !== registrationId || typeof value.checksEnabled !== 'boolean' || !isIso(value.updatedAt) || !isObject(value.models) || !(value.consent === undefined || value.consent === null || validConsent(value.consent)) || // codexRelease shares the last npm check between the sidecar and CLI processes. !(value.codexRelease === undefined || validRelease(value.codexRelease)) || entries.length > MAX_MODELS || !entries.every(([slug, record]) => matches(slug, SLUG) && validRecord(record))) throw storeCorrupt(); return { checksEnabled: value.checksEnabled, consent: value.consent ?? null, models: new Map(entries), ...(value.codexRelease && { codexRelease: value.codexRelease }) }; } async function load(directory) { const value = directory ? await readRecord(storePath(directory), cfg) : null; return value === null ? emptyStore() : parseStore(value); } // A missing, unsafe or corrupt store reads as checks off with no records. async function readStore() { let store; try { store = await load(await storeDirectory()); } catch { store = { ...emptyStore(), unsafe: true }; } known = new Map(store.models); return store; } // Oldest records go first when the store would exceed its caps. function serialize(store, at) { const entries = [...store.models].sort(([, a], [, b]) => Date.parse(b.checkedAt) - Date.parse(a.checkedAt)).slice(0, MAX_MODELS); for (;;) { const bytes = Buffer.from(JSON.stringify({ schemaVersion: 1, registrationId, checksEnabled: store.checksEnabled, consent: store.consent ?? null, updatedAt: iso(at), models: Object.fromEntries(entries), ...(store.codexRelease && { codexRelease: store.codexRelease }), })); if (bytes.length <= MAX_FILE || !entries.length) return bytes; entries.pop(); } } // Locked read-modify-write. The file is read again inside the lock and each caller changes only its // own keys. A corrupt or unsafe store is never overwritten, except that turning checks off may replace // a safe file that does not parse. async function update(mutate, { wait = true, deadline = Infinity, replaceCorrupt = false } = {}) { const directory = await storeDirectory(true); return locked(join(directory, `${registrationId}.lock`), cfg, async () => { let current; let replace = false; try { current = await load(directory); } catch (error) { if (!(replaceCorrupt && error?.corrupt)) throw error; current = emptyStore(); replace = true; } const at = cfg.now(); const next = mutate(current, at); if (next !== current || replace) await writeRecord(storePath(directory), serialize(next, at), cfg); known = new Map(next.models); return next; }, { wait, deadline }); } // --- Catalog --- const deadlineOf = (budgetMs) => budgetMs === undefined ? Infinity : cfg.now() + budgetMs; // Ends a wait at a deadline on cfg.now()'s clock. const cutoff = (deadline) => Number.isFinite(deadline) ? AbortSignal.timeout(Math.max(1, deadline - cfg.now())) : undefined; async function leaseFor(signal) { const merged = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; merged.throwIfAborted(); const lease = await waitFor(Promise.resolve(getLease({ signal: merged })), merged); if (!matches(lease?.accessToken, TOKEN)) throw new Error('Invalid lease.'); return lease; } // The npm check another process shared through the store counts, unless it is dated in the future // (a clock that jumped back). function knownVersion(store) { const shared = Date.parse(store.codexRelease?.checkedAt); if (shared <= cfg.now() && !(shared <= versionCache.fetchedAt)) Object.assign(versionCache, { version: store.codexRelease.version ?? versionCache.version, fetchedAt: shared }); return newerVersion(versionCache.version, pinnedClientVersion); } async function catalogVersion() { knownVersion(await readStore()); const before = versionCache.fetchedAt; const version = await resolveCatalogClientVersion({ pinned: pinnedClientVersion, cache: versionCache, fetchImpl: cfg.fetchImpl, now: cfg.now }); if (versionCache.fetchedAt !== before) { const checkedAt = versionCache.fetchedAt; const codexRelease = { ...(versionCache.version && { version: versionCache.version }), checkedAt: iso(checkedAt) }; // Never waits on a busy lock: the catalog answers n8n first. await update((latest, at) => { const stored = Date.parse(latest.codexRelease?.checkedAt); return stored >= checkedAt && stored <= at ? latest : { ...latest, codexRelease }; }, { wait: false }).catch(() => {}); } return version; } async function fetchCatalog(lease, readOnly) { let failureStatus = 503; try { // Read-only callers use the last shared npm check, or the pin, and write nothing. const clientVersion = readOnly ? knownVersion(await readStore()) : await catalogVersion(); closing.signal.throwIfAborted(); const response = await cfg.fetchImpl(`${MODELS_URL}?client_version=${clientVersion}`, { headers: { authorization: `Bearer ${lease.accessToken}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.any([closing.signal, AbortSignal.timeout(CATALOG_TIMEOUT_MS)]), }); if (!response.ok) { if (response.status === 401) failureStatus = 401; await response.body?.cancel().catch(() => {}); throw new Error('Catalog request failed.'); } const entries = catalogEntries(await readJson(response, MAX_CATALOG_BODY), lease.accessToken); catalog = { entries, ids: new Set(entries.map(({ id }) => id)), clientVersion, fetchedAt: cfg.now() }; return catalog; } catch { // A rejected sign-in surfaces now instead of hiding behind an hour of cached lists. if (failureStatus !== 401 && catalog && ageOf(cfg.now(), catalog.fetchedAt) < STALE_CATALOG_MS) return catalog; throw catalogUnavailable(failureStatus); } } // Every caller takes its own lease first; lease errors propagate unchanged and never fall back to the // cached list. Concurrent callers then share one request, which runs on its own timeout. async function loadCatalog(signal, { readOnly = false } = {}) { if (catalog && ageOf(cfg.now(), catalog.fetchedAt) < CATALOG_TTL_MS) return catalog; const lease = await leaseFor(signal); signal?.throwIfAborted(); if (!refreshing) { refreshing = fetchCatalog(lease, readOnly).finally(() => { refreshing = null; }); refreshing.catch(() => {}); } return waitFor(refreshing, signal); } // --- Probes --- // One tiny request: { state, code? } to record, { stop } to end the run, or {} to retry next run. async function probe(entry, lease, signal) { let response; try { response = await cfg.fetchImpl(RESPONSES_URL, { method: 'POST', headers: { authorization: `Bearer ${lease.accessToken}`, 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ model: entry.id, instructions: 'Reply with OK.', input: [{ role: 'user', content: [{ type: 'input_text', text: 'OK' }] }], stream: true, store: false, ...(entry.effort && { reasoning: { effort: entry.effort } }), }), redirect: 'error', signal, }); if (response.status === 401) return { stop: 'reauthorize' }; if (response.status === 429) return { stop: 'usage_limit' }; if (response.ok) return await streamOutcome(response); if (response.status < 400 || response.status >= 500) return {}; let body = null; if (response.status === 400 || response.status === 404) { try { body = await readJson(response, MAX_FILE); } catch { /* An unreadable error never blames the model. */ } } const code = classifyModelRejection(response.status, body, { source: 'probe' }); return code ? { state: 'failed', code } : { stop: 'probe_rejected' }; } catch { return {}; } finally { // Unread bodies must not hold the connection open. await response?.body?.cancel().catch(() => {}); } } // One model at a time; each result is saved as it lands. Returns the counts, the catalog the run used // and, when it had none, why. Only the enabling run probes while checks are off. async function run({ limit, spacingMs, deadline, saveDeadline = deadline, enabling }) { const result = { checked: 0, verified: 0, failed: 0 }; const opening = cutoff(deadline); let current; try { current = await loadCatalog(opening); } catch (error) { if (closing.signal.aborted) return { result: { ...result, stoppedReason: 'checks_off' }, catalog: null, catalogError: 'catalog_unavailable' }; if (opening?.aborted || cfg.now() >= deadline) return { result: { ...result, stoppedReason: 'time_limit' }, catalog: null, catalogError: 'catalog_unavailable' }; if (catalogErrorOf(error) === 'catalog_unavailable') return { result: error.status === 401 ? { ...result, stoppedReason: 'reauthorize' } : result, catalog: null, catalogError: 'catalog_unavailable' }; return { result: { ...result, stoppedReason: error?.recovery === 'reauthorize' ? 'reauthorize' : 'lease_unavailable' }, catalog: null, catalogError: 'registration_unavailable' }; } const stop = (stoppedReason) => ({ result: { ...result, stoppedReason }, catalog: current }); // Turning checks off, an approval that no longer matches or close() stops the run; a model another // process settled is skipped. The enabling run probes while checks are still off. const recheck = async (id) => { if (closing.signal.aborted) return 'checks_off'; const store = await readStore(); if (enabling && store.unsafe) throw storeUnsafe(); if (!enabling && !active(store)) return 'checks_off'; return due(store.models.get(id), cfg.now()) ? 'probe' : 'skip'; }; for (const entry of current.entries) { if (result.checked >= limit) break; if (!due((await readStore()).models.get(entry.id), cfg.now())) continue; if (result.checked > 0 && spacingMs > 0) await cfg.sleep(spacingMs, undefined, { signal: closing.signal }).catch(() => {}); if (cfg.now() >= deadline) return stop('time_limit'); let next = await recheck(entry.id); if (next === 'checks_off') return stop(next); if (next === 'skip') continue; const budget = cutoff(deadline); const signal = AbortSignal.any([closing.signal, AbortSignal.timeout(PROBE_TIMEOUT_MS), ...(budget ? [budget] : [])]); let lease; try { lease = await leaseFor(signal); } catch (error) { return stop(closing.signal.aborted ? 'checks_off' : budget?.aborted ? 'time_limit' : error?.recovery === 'reauthorize' ? 'reauthorize' : 'lease_unavailable'); } // The lease can take a while, so check again right before sending. next = await recheck(entry.id); if (next === 'checks_off') return stop(next); if (next === 'skip') continue; result.checked += 1; const outcome = await probe(entry, lease, signal); if (outcome.stop) return stop(outcome.stop); if (outcome.state) { await update((latest, at) => withModel(latest, entry.id, { state: outcome.state, checkedAt: iso(at), source: 'probe', ...(outcome.code && { code: outcome.code }) }), { deadline: saveDeadline }); result[outcome.state] += 1; } if (budget?.aborted) return stop('time_limit'); } return { result, catalog: current }; } // StatusView: rows in catalog order, at most 64; "failed" only while the failure still hides the model. function view(store, current, { catalogError, lastRun } = {}) { const on = active(store); const head = { checksEnabled: on, clientVersion: current?.clientVersion ?? knownVersion(store), catalogCheckedAt: current ? iso(current.fetchedAt) : null, ...(catalogError && { catalogError }), ...(lastRun && { lastRun }) }; if (!current) return { ...head, models: [] }; const at = cfg.now(); const shown = new Set(listedEntries(current.entries, store, at).map(({ id }) => id)); return { ...head, models: current.entries.slice(0, MAX_ROWS).map(({ id, display_name }) => { const record = store.models.get(id); const state = record?.state === 'verified' ? 'verified' : hides(record, on, at) ? 'failed' : 'unchecked'; return { id, display_name, state, listed: shown.has(id), ...(state !== 'unchecked' && { checkedAt: record.checkedAt }) }; }) }; } // --- Operations --- async function verifyNow({ limit = 12, spacingMs = 0, budgetMs, enabling = false } = {}) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_MODELS || !Number.isSafeInteger(spacingMs) || spacingMs < 0 || !validBudget(budgetMs) || typeof enabling !== 'boolean') throw new TypeError('Invalid model check options.'); return (await run({ limit, spacingMs, deadline: deadlineOf(budgetMs), enabling })).result; } async function listModels({ signal } = {}) { const store = await readStore(); const current = await loadCatalog(signal); const at = cfg.now(); const on = active(store); if (on && !closing.signal.aborted && !background && ageOf(at, pausedAt) >= PAUSE_MS && current.entries.some(({ id }) => due(store.models.get(id), at))) { // Never awaited here: n8n gets the list now, and new models show once they answer. A run that // stopped or left a model unanswered pauses background checks in this process for an hour. // ponytail: no cross-process run lock, so a repeated checks-on can probe a model this run probes too. background = run({ limit: 8, spacingMs: 5_000, deadline: Infinity, enabling: false }) .then(({ result }) => { if (result.stoppedReason || result.checked > result.verified + result.failed) pausedAt = cfg.now(); }, () => { pausedAt = cfg.now(); }) .finally(() => { background = null; }); } return { models: listedEntries(current.entries, store, at).map(({ id, display_name }) => ({ id, display_name })), clientVersion: current.clientVersion, checksEnabled: on }; } async function recordOutcome({ model, outcome } = {}) { try { const state = outcome === 'completed' ? 'verified' : outcome === 'model_rejected' ? 'failed' : undefined; // Only models from the last catalog: n8n defaults, typos and pasted keys never reach the store. if (!state || typeof model !== 'string' || !catalog?.ids.has(model) || settled(known.get(model), state, cfg.now())) return; const record = (at) => ({ state, checkedAt: iso(at), source: 'traffic' }); known.set(model, record(cfg.now())); await update((store, at) => settled(store.models.get(model), state, at) ? store : withModel(store, model, record(at))); } catch { /* Passive learning never affects traffic. */ } } // --- Request counts: ${storageRoot}/activity/${registrationId}.{json,lock} --- let pending = newTally(); let writeTimer = null; let writing = null; let writeAgain = false; const activityDirectory = (create = false) => ownedDirectory(cfg, storageRoot, registrationId, ACTIVITY_DIR, create); // Synchronous and in memory only, so counting never delays or fails a request. A model OpenAI accepted, // or one the last catalog lists, keeps its ID; n8n defaults, typos and pasted keys count as "other". function recordActivity(entry) { try { const { model, outcome, usage, code, accepted = false } = entry ?? {}; const at = cfg.now(); const id = matches(model, SLUG) && (accepted === true || catalog?.ids.has(model)) ? model : OTHER_MODEL; const counts = dayCounts(pending.days, dayOf(at), id); counts.requests = plus(counts.requests, 1); if (['completed', 'failed', 'incomplete'].includes(outcome)) counts[outcome] = plus(counts[outcome], 1); if (outcome === 'completed') { addCounts(counts, tokenCounts(usage)); Object.assign(pending, { event: null, clearedAt: at }); } else if (outcome === 'failed' && Object.hasOwn(USAGE_EVENTS, code)) pending.event = { at: iso(at), code }; pending.firstAt ??= at; trimDays(pending.days); if (closing.signal.aborted) void flushActivity(); else if (!writeTimer) { writeTimer = cfg.setTimer(() => { writeTimer = null; void flushActivity(); }, ACTIVITY_WRITE_MS); writeTimer?.unref?.(); } } catch { /* Counting never affects traffic. */ } } // Adds the pending counts to the record under its lock. A safe record that does not parse is replaced, // an unsafe one is never touched, and counts that could not be written wait for the next write. async function writeActivity() { if (!pending.days.size) return; const batch = pending; pending = newTally(); try { const directory = await activityDirectory(true); const path = join(directory, `${registrationId}.json`); await locked(join(directory, `${registrationId}.lock`), cfg, async () => { let stored = null; try { const value = await readRecord(path, cfg, MAX_ACTIVITY_FILE); if (value !== null) stored = parseActivity(value, registrationId); } catch (error) { if (!error?.corrupt) throw error; } const record = activityRecord(mergeActivity(stored, batch, cfg.now(), registrationId)); await writeRecord(path, Buffer.from(JSON.stringify(record)), cfg, MAX_ACTIVITY_FILE); }, { deadline: cfg.now() + ACTIVITY_LOCK_MS }); } catch { pending = requeue(batch, pending); } } // One write at a time; a flush asked for during a write runs once after it. Never rejects. function flushActivity() { if (writing) { writeAgain = true; return writing; } writing = (async () => { do { writeAgain = false; await writeActivity(); } while (writeAgain); })().finally(() => { writing = null; }); return writing; } // Read-only: the stored record, or null before the first write. async function activity() { try { const directory = await activityDirectory(); const value = directory ? await readRecord(join(directory, `${registrationId}.json`), cfg, MAX_ACTIVITY_FILE) : null; return value === null ? null : activityRecord(parseActivity(value, registrationId)); } catch { throw activityUnsafe(); } } // Read-only apart from the lease, which may refresh the SIWC token: no npm request, no store write. async function status({ budgetMs } = {}) { if (!validBudget(budgetMs)) throw new TypeError('Invalid status options.'); const deadline = deadlineOf(budgetMs); const signal = cutoff(deadline); let current = null; let failure = {}; try { current = await loadCatalog(signal, { readOnly: true }); } // Status runs no checks, so a timeout is reported as an unreadable catalog, never as a check run. catch (error) { failure = { catalogError: signal?.aborted || cfg.now() >= deadline ? 'catalog_unavailable' : catalogErrorOf(error) }; } return view(await readStore(), current, failure); } async function setChecks({ enabled, budgetMs } = {}) { if (typeof enabled !== 'boolean' || !validBudget(budgetMs)) throw new TypeError('Model checks need enabled: true or false.'); const deadline = deadlineOf(budgetMs); if (!enabled) { // No network call, so checks can be turned off while OpenAI or the sign-in is down. const store = await update((current) => current.checksEnabled || current.consent ? { ...current, checksEnabled: false, consent: null } : current, { deadline, replaceCorrupt: true }); return view(store, catalog); } // Probe first and save after, so n8n switches to the verified list in one step and the sidecar starts // no run of its own meanwhile. Nothing is spent when the results could not be saved. if ((await readStore()).unsafe) throw storeUnsafe(); const checked = await run({ limit: 12, spacingMs: 0, enabling: true, saveDeadline: deadline, deadline: Number.isFinite(deadline) ? deadline - SAVE_RESERVE_MS : Infinity }); const store = await update((current, at) => active(current) ? current : { ...current, checksEnabled: true, consent: { acceptedAt: iso(at), noticeVersion: MODEL_CHECKS_NOTICE } }, { deadline }); return view(store, checked.catalog, { catalogError: checked.catalogError, lastRun: checked.result }); } // Stops background checks for good: work in flight is aborted, a running run stops at its next check // and no new run starts. Pending request counts are written, and any counted later are written at once. // Resolves once the running run has stopped and the counts are written; never rejects. function close() { closing.abort(); if (writeTimer) { cfg.clearTimer(writeTimer); writeTimer = null; } return Promise.all([background?.then(() => {}, () => {}), flushActivity()]).then(() => {}); } return { listModels, recordOutcome, recordActivity, activity, status, setChecks, verifyNow, close }; } const COMMANDS = { status: (discovery, budgetMs) => discovery.status({ budgetMs: budgetMs(STATUS_DEADLINE_MS) }), 'checks-on': (discovery, budgetMs) => discovery.setChecks({ enabled: true, budgetMs: budgetMs(CHECKS_ON_DEADLINE_MS) }), 'checks-off': (discovery) => discovery.setChecks({ enabled: false }), // Read-only: prints the stored request counts, or null before the first one. usage: (discovery) => discovery.activity(), }; export async function runModelDiscoveryCli({ command, env = process.env, output = process.stdout, deps } = {}) { // One deadline from the start covers lock waits, the lease, npm, the catalog and probes. A lease call // still pending at the deadline settles before the process exits: nothing here calls process.exit(). const now = deps?.now ?? Date.now; const started = now(); const budgetMs = (deadlineMs) => Math.max(1, started + deadlineMs - now()); let result; let exitCode = 0; try { if (!Object.hasOwn(COMMANDS, command)) throw new ModelDiscoveryError('invalid_command', 'Unknown command.'); const session = await import('./siwc-session.mjs'); let storageRoot; try { storageRoot = session.resolveSiwcStorageRoot({ env }); } catch { throw invalidConfiguration(); } const registrationId = env.RELMIO_REGISTRATION_ID; const runtimeId = env.RELMIO_RUNTIME_ID; if (!matches(registrationId, ID) || !matches(runtimeId, ID)) throw invalidConfiguration(); const getAccessToken = deps?.getAccessToken ?? session.getAccessToken; const discovery = createModelDiscovery({ storageRoot, registrationId, pinnedClientVersion: CODEX_CLI_VERSION, deps, getLease: ({ signal } = {}) => getAccessToken({ storageRoot, registrationId }, { runtimeId, minValidityMs: 60_000, signal }), }); result = await COMMANDS[command](discovery, budgetMs); } catch (error) { exitCode = 1; result = error instanceof ModelDiscoveryError && CLI_ERRORS.has(error.code) ? { error: error.code, message: error.message } : { error: 'discovery_failed', message: 'Model discovery failed.' }; } output.write(`${JSON.stringify(result)}\n`); return exitCode; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { process.exitCode = await runModelDiscoveryCli({ command: process.argv[2] }); }