Files

315 lines
18 KiB
JavaScript

// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): core's side of the limit checks. It loads only the
// `limit-check` modules the organization file switches on, one loader per business, and asks
// them through the loader alone: mayLaunch before a launch, mustStop after each turn's usage end,
// report for Settings and `mosaic limits`. Each check gets a copy of the business's launch limits
// and its per-turn usage records (usage.mjs turnRecords).
//
// Fail closed (lead decision 150). A check that throws, answers in the wrong shape, answers with
// a promise, or is switched on but not loaded (a refused load keeps the old modules) refuses the
// launch, stops the session at the end of its turn, and shows every limit as `check failed`. An
// organization file that can't be read refuses and stops the same way, with check null; report
// then throws, as the verb's own read does.
//
// refresh, mayLaunch and mustStop are async: they re-read the file and reload first. report is
// sync, so the organization verbs stay sync; it uses the modules the last refresh loaded.
import { createLoader, isDailyModelQuota } from '../../plugins/src/loader.mjs';
import { turnRecords } from './usage.mjs';
const KIND = 'limit-check';
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const CODE = /^[a-z][a-z0-9-]*$/;
const own = (value, key) => value !== null && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, key);
const plainObject = value => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
const exact = (value, keys) => plainObject(value) && Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k));
const amount = value => (Number.isSafeInteger(value) && value >= 0) || (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value));
const turns = value => Number.isSafeInteger(value) && value >= 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const HEX12 = /^[a-f0-9]{12}$/;
// The key lookup's own refusals pass through by name; any other throw is proxy-key-refused.
const LOOKUP_CODES = ['proxy-key-refused', 'key-quota-no-model-key'];
// The ids that name a daily per-key quota (row 333), in file order.
const dailyKeyIds = limits => Object.entries(limits?.tokens?.keys ?? {}).filter(([k, entry]) => isDailyModelQuota(k, entry)).map(([k]) => k);
// Row 333 R3 (#1831): one array of key ids per hired agent, no id in two.
const validLineages = value => {
if (!Array.isArray(value) || value.some(l => !Array.isArray(l) || !l.length || l.some(id => typeof id !== 'string' || !HEX12.test(id)))) return false;
const ids = value.flat();
return new Set(ids).size === ids.length;
};
// Every token limit in launch.limits, in file order, at its schema depth only: tokens.instances.X,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and tokens.daily.A (row 126). The agent limits (total, providers,
// models) are never checks' limits, whatever their names, so a provider called `limit` there isn't
// taken for one. The settings writer (business launch-settings.mjs) walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
export function limitLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
if (!plainObject(value)) return;
for (const [key, entry] of Object.entries(value)) {
if (depth > 1) walk(entry, depth - 1, [...path, key]);
else if (plainObject(entry)) out.push({ path: [...path, key], limit: entry.limit, since: entry.since });
}
};
const tokens = plainObject(limits) ? limits.tokens : undefined;
if (plainObject(tokens)) {
for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ['tokens', kind]);
}
return out;
}
class Failure extends Error {
constructor(code) { super(code); this.code = code; }
}
// makeLoader is the plug-in loader's factory; tests pass a scratch tree's copy to load fixture checks.
// Row 333: keyForSubject names the subject's model key id: the agent's current key for a launch,
// the run's bound key for mustStop. R3: keyLineages lists the business's hired agents' key
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
// Row 333 R3: an orphaned key quota names an id in no hired agent's lineage. It limits nothing
// and refuses nothing. One value-free log line per leaf when it becomes orphaned, not per launch.
// R4: every refresh observes too, and a business with no daily key quota left remembers none, so a
// leaf removed and added back logs again. The set lives in memory: a bus start is a fresh
// observation and may log an unchanged orphan once more.
const orphansLogged = new Map();
function noteOrphans(business, limits, lineages) {
const ids = dailyKeyIds(limits).filter(id => !lineages.some(l => l.includes(id)));
const before = orphansLogged.get(business) ?? new Set();
for (const id of ids) if (!before.has(id)) log({ event: 'limit-key-orphaned', business, path: ['tokens', 'keys', id] });
orphansLogged.set(business, new Set(ids));
return ids;
}
// The orphaned ids now, [] when no daily key quota is set, or null when the lineages can't be
// read. Then the remembered set keeps only the ids the document still holds and gains none: an
// unreadable lookup proves no leaf orphaned, but the document does prove which leaves were removed.
function observeOrphans(business, limits) {
const ids = dailyKeyIds(limits);
if (!ids.length) { orphansLogged.set(business, new Set()); return []; }
const lineages = lineagesFor(business);
if (lineages) return noteOrphans(business, limits, lineages);
const before = orphansLogged.get(business);
if (before) orphansLogged.set(business, new Set([...before].filter(id => ids.includes(id))));
return null;
}
// The business's lineages, or null when they can't be read or are malformed.
function lineagesFor(business) {
let value;
try { value = keyLineages(business); } catch { return null; }
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
return loaders.get(business);
};
// What one business has now: its limits, its records, and its switched-on checks. A check the
// file switches on that the loader doesn't have (a refused load) is not loaded.
function state(business, b = undefined) {
if (b === undefined) {
try { b = read(business); } catch { throw new Failure('organization-unreadable'); }
}
const setting = b?.plugins?.[KIND] ?? [];
const loader = loaderFor(business);
// The loader seeds every kind with an empty list, so a kind never loaded, or refused on its
// first load, has nothing switched on.
const names = loader.loaded()[KIND];
const modules = loader.modules(KIND);
let records;
try { records = turnRecords(store, business); } catch { throw new Failure('records-unreadable'); }
return {
limits: b?.launch?.limits ?? {},
records,
checks: same(setting, names) ? names.map((name, i) => ({ name, module: modules[i] })) : null,
missing: setting.find(name => !names.includes(name)) ?? setting[0] ?? null,
};
}
async function refresh(business) {
let b;
try { b = read(business); } catch { return; }
observeOrphans(business, b?.launch?.limits ?? {});
await loaderFor(business).load({ [KIND]: b?.plugins?.[KIND] ?? [] });
}
// Ask one check one export. The answer comes back, or a code: the check's own code if it threw
// one in the plug-in code shape, check-threw for any other throw, malformed-answer otherwise.
function ask(business, check, name, args, valid) {
try {
const answer = check.module[name](structuredClone(args));
if (answer !== null && (typeof answer === 'object' || typeof answer === 'function') && typeof answer.then === 'function') {
Promise.resolve(answer).catch(() => {});
throw new Failure('malformed-answer');
}
if (!valid(answer)) throw new Failure('malformed-answer');
return { answer };
} catch (e) {
const code = e instanceof Failure ? e.code : typeof e?.code === 'string' && CODE.test(e.code) ? e.code : 'check-threw';
log({ event: 'limit-check-failed', business, check: check.name, export: name, code });
return { code };
}
}
const validHit = (hit, leaves) =>
exact(hit, ['path', 'limit', 'count', 'unknownTurns']) &&
leaves.some(l => same(l.path, hit.path) && l.limit === hit.limit) && amount(hit.count) && turns(hit.unknownTurns);
// mayLaunch and mustStop: every switched-on check is asked in order; the first refusal or
// stop wins. key is the answer's boolean (allow, stop) and go its value when nothing is hit.
async function decide(business, subject, name, key, go, halt, context = {}) {
await refresh(business);
let st;
try { st = state(business); } catch (e) {
log({ event: 'limit-check-failed', business, check: null, export: name, code: e.code });
return halt({ check: null, code: e.code });
}
if (!st.checks) {
log({ event: 'limit-check-failed', business, check: st.missing, export: name, code: 'not-loaded' });
return halt({ check: st.missing, code: 'not-loaded' });
}
if (!st.checks.length) return { [key]: go };
const leaves = limitLeaves(st.limits);
let lineages;
if (dailyKeyIds(st.limits).length) {
let modelKeyId, code = 'proxy-key-refused';
try {
if (name === 'mustStop' && (typeof context?.run !== 'string' || !context.run)) throw new Failure('proxy-key-refused');
modelKeyId = keyForSubject(business, { ...subject, ...context });
} catch (e) { if (LOOKUP_CODES.includes(e?.code)) code = e.code; }
if (typeof modelKeyId === 'string' && HEX12.test(modelKeyId)) lineages = lineagesFor(business);
// The subject's key must sit in exactly one hired agent's lineage, read in the same tick.
if (!lineages?.some(l => l.includes(modelKeyId))) {
log({ event: 'limit-check-failed', business, check: null, export: name, code });
return halt({ check: null, code });
}
subject = { ...subject, modelKeyId };
noteOrphans(business, st.limits, lineages);
}
const args = { business, limits: st.limits, subject, records: st.records, now: new Date().toISOString(), ...(lineages ? { lineages } : {}) };
for (const check of st.checks) {
const { answer, code } = ask(business, check, name, args, a =>
exact(a, [key]) ? a[key] === go : exact(a, [key, 'hit']) && a[key] === !go && validHit(a.hit, leaves));
if (code) return halt({ check: check.name, code });
if (answer[key] !== go) {
log({ event: 'limit-reached', business, check: check.name, export: name, subject, ...answer.hit });
return halt({ check: check.name, hit: answer.hit });
}
}
return { [key]: go };
}
const subjectOf = ({ instance, provider = null, model = null, account = null }) =>
({ instance, provider, model, account });
// { allow: true } | { allow: false, check, hit } | { allow: false, check, code }
const mayLaunch = (business, subject) =>
decide(business, subjectOf(subject), 'mayLaunch', 'allow', true, why => ({ allow: false, ...why }));
// { stop: false } | { stop: true, check, hit } | { stop: true, check, code }
const mustStop = (business, subject, context = {}) =>
decide(business, subjectOf(subject), 'mustStop', 'stop', false, why => ({ stop: true, ...why }), context);
const validEntry = (e, leaves) => {
const leaf = plainObject(e) && Array.isArray(e.path) && leaves.find(l => same(l.path, e.path));
if (!leaf || e.limit !== leaf.limit || e.since !== leaf.since) return false;
if (exact(e, ['path', 'limit', 'since', 'notEnforced'])) return typeof e.notEnforced === 'string' && e.notEnforced.length > 0;
return exact(e, ['path', 'limit', 'since', 'count', 'kinds', 'unknownTurns']) && amount(e.count) &&
exact(e.kinds, KINDS) && KINDS.every(k => amount(e.kinds[k])) && turns(e.unknownTurns);
};
// One entry per limit, in file order:
// { path, limit, since, status: 'counted', check, count, kinds, unknownTurns }
// { path, limit, since, status: 'not-enforced', reason } (reason null when no check names it)
// { path, limit, since, status: 'check-failed', check, code }
// A failed check fails every limit, so a count never shows beside a check that couldn't answer.
function report(business) {
// An unreadable file fails the report, as it fails the verb's own read.
const b = read(business);
const leaves = limitLeaves(b?.launch?.limits ?? {});
const failedAll = (check, code) => leaves.map(({ path, limit, since }) => ({ path, limit, since, status: 'check-failed', check, code }));
let st;
try { st = state(business, b); } catch (e) { return failedAll(null, e.code); }
if (!st.checks) return failedAll(st.missing, 'not-loaded');
// Row 333 R3: a key quota's count is its whole lineage's, so a report needs the lineages too.
let lineages;
if (st.checks.length && dailyKeyIds(st.limits).length) {
lineages = lineagesFor(business);
if (!lineages) return failedAll(null, 'proxy-key-refused');
noteOrphans(business, st.limits, lineages);
}
const answers = [];
for (const check of st.checks) {
const { answer, code } = ask(business, check, 'report', { business, limits: st.limits, records: st.records, now: new Date().toISOString(), ...(lineages ? { lineages } : {}) },
a => Array.isArray(a) && a.every(e => validEntry(e, leaves)) && new Set(a.map(e => JSON.stringify(e.path))).size === a.length);
if (code) return failedAll(check.name, code);
answers.push({ check: check.name, entries: answer });
}
return leaves.map(({ path, limit, since }) => {
const found = answers.flatMap(({ check, entries }) => entries.filter(e => same(e.path, path)).map(e => ({ check, e })));
const counted = found.find(({ e }) => Object.hasOwn(e, 'count'));
if (counted) {
const { count, kinds, unknownTurns } = counted.e;
return { path, limit, since, status: 'counted', check: counted.check, count, kinds, unknownTurns };
}
const named = found.find(({ e }) => Object.hasOwn(e, 'notEnforced'));
return { path, limit, since, status: 'not-enforced', reason: named ? named.e.notEnforced : null };
});
}
// Row 333 R3: the daily key quotas that match no hired agent, for the Console usage view. Null
// when the file or the lineages can't be read; the view then says nothing either way.
function orphanedKeys(business) {
let b;
try { b = read(business); } catch { return null; }
return observeOrphans(business, b?.launch?.limits ?? {});
}
return { refresh, mayLaunch, mustStop, report, orphanedKeys };
}
// Host metadata adapters, extracted from runtime so ownership cases are testable. records is
// Credentials.status(): metadata only, never a key.
// Row 333 R3: an agent's lineage is its current key id, its retiring ids and its earlier ids.
const lineageOf = record => [record.keyId, ...(record.retiring ?? []).map(r => r.keyId), ...(record.previous ?? []).map(r => r.keyId)];
export function modelUsageKey(records, runs, business, subject) {
const fail = (code = 'proxy-key-refused') => { throw new Failure(code); };
const instance = business + '/' + subject.instance;
const owners = records.filter(r => r.service === 'model' && r.instance === instance);
// Row 333 R3 ruling 2: an agent with no model key (no proxy, or not issued) can't be matched to a
// key quota. Still a refusal, under its own name.
if (!owners.length || (owners.length === 1 && owners[0].keyId === undefined)) fail('key-quota-no-model-key');
if (owners.length !== 1) fail();
const record = owners[0], ids = lineageOf(record);
if (ids.some(id => typeof id !== 'string' || !HEX12.test(id)) || new Set(ids).size !== ids.length) fail();
// A key ID must identify exactly this owner, over every generation either agent ever held.
for (const other of records.filter(r => r.service === 'model' && r !== record))
if (lineageOf(other).some(id => ids.includes(id))) fail();
if (!subject.run) return record.keyId;
// A run is bound to a key the proxy still accepts: current or retiring, never an earlier one.
const run = runs.get(business + ':' + subject.run);
if (!run || run.instance !== instance || ![record.keyId, ...(record.retiring ?? []).map(r => r.keyId)].includes(run.keyId)) fail();
return run.keyId;
}
// Every hired agent's lineage in one business. Any malformed id, or an id in two lineages
// anywhere on the host, refuses.
export function modelKeyLineages(records, business) {
const seen = new Set(), out = [];
for (const record of records.filter(r => r.service === 'model' && r.keyId !== undefined)) {
const ids = lineageOf(record);
for (const id of ids) {
if (typeof id !== 'string' || !HEX12.test(id) || seen.has(id)) throw new Failure('proxy-key-refused');
seen.add(id);
}
if (typeof record.instance === 'string' && record.instance.startsWith(business + '/')) out.push(ids);
}
return out;
}
// Row 333 R4 (#1831): the settings writer's lookup. The lineage in `business` that holds keyId, or
// [] when none does. Ids only, from the bus's own records; the same checks refuse.
export function modelKeyLineage(records, business, keyId) {
return modelKeyLineages(records, business).find(l => l.includes(keyId)) ?? [];
}