Ctrl K
ComunidadGuías…

src/services/model-discovery.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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] });
}