git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
177 lines
9.0 KiB
JavaScript
177 lines
9.0 KiB
JavaScript
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
|
|
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
|
|
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
|
import { BusinessError, refuse } from "./errors.mjs";
|
|
|
|
const name = (value, at) => requireString(value, at, { max: 200 });
|
|
const count = (value, at) => {
|
|
if (!Number.isSafeInteger(value) || value < 0) refuse(`${at} must be a whole number of 0 or more`);
|
|
return value;
|
|
};
|
|
const own = (object, key) => Object.hasOwn(object ?? {}, key) ? object[key] : undefined;
|
|
|
|
export function checkModels(value, where = "launch.models") {
|
|
requireObject(value, where);
|
|
if (!Object.keys(value).length) refuse(`${where} must name at least one model`);
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
|
|
requireId(key, where);
|
|
requireObject(entry, `${where}.${key}`);
|
|
const moduleBacked = Object.hasOwn(entry, "module") || Object.hasOwn(entry, "upstream");
|
|
rejectUnknownKeys(entry, moduleBacked ? ["provider", "model", "module", "upstream"] : entry.provider === "proxy" ? ["provider", "model", "route"] : ["provider", "model"], `${where}.${key}`);
|
|
if (moduleBacked) {
|
|
requireId(entry.module, `${where}.${key}.module`);
|
|
requireId(entry.upstream, `${where}.${key}.upstream`);
|
|
if (typeof entry.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(entry.model)) refuse(`${where}.${key}.model is invalid`);
|
|
}
|
|
if (entry.provider === "proxy" && !moduleBacked) {
|
|
if (!Object.hasOwn(entry, "route")) throw Object.assign(new BusinessError(`${where}.${key}.route is required`), { code: "proxy-route-missing" });
|
|
if (typeof entry.route !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(entry.route)) throw Object.assign(new BusinessError(`${where}.${key}.route is invalid`), { code: "proxy-route-unknown" });
|
|
}
|
|
return [key, {
|
|
provider: name(entry.provider, `${where}.${key}.provider`),
|
|
model: name(entry.model, `${where}.${key}.model`),
|
|
...(moduleBacked ? { module: entry.module, upstream: entry.upstream } : {}),
|
|
...(entry.provider === "proxy" && !moduleBacked ? { route: entry.route } : {}),
|
|
}];
|
|
}));
|
|
}
|
|
|
|
// Row 125 (#1619, REQ-LIMIT-3): one token limit, a count of 0 or more and the
|
|
// canonical time it was set or last reset. The settings writer stamps `since`.
|
|
const SINCE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
const tokenLimit = (value, at) => {
|
|
requireObject(value, at);
|
|
rejectUnknownKeys(value, ["limit", "since"], at);
|
|
const since = value.since;
|
|
if (typeof since !== "string" || !SINCE.test(since) || Number.isNaN(Date.parse(since)) ||
|
|
new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`);
|
|
return { limit: count(value.limit, `${at}.limit`), since };
|
|
};
|
|
// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day
|
|
// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00
|
|
// is refused) that Intl accepts.
|
|
const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
const dailyQuota = (value, at) => {
|
|
requireObject(value, at);
|
|
rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at);
|
|
const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at);
|
|
if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`);
|
|
const timeZone = value.timeZone;
|
|
let known = typeof timeZone === "string" && ZONE.test(timeZone);
|
|
if (known) {
|
|
try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; }
|
|
}
|
|
if (!known) refuse(`${at}.timeZone must be an IANA time zone name`);
|
|
return { limit, since, reset: value.reset, timeZone };
|
|
};
|
|
const tokenMap = (value, at, key, each) => {
|
|
requireObject(value, at);
|
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)]));
|
|
};
|
|
|
|
// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional.
|
|
export function checkTokenLimits(value, where = "launch.limits.tokens") {
|
|
requireObject(value, where);
|
|
rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where);
|
|
const out = {};
|
|
if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit);
|
|
if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit);
|
|
if (Object.hasOwn(value, "models")) {
|
|
out.models = tokenMap(value.models, `${where}.models`, name, (models, at) => tokenMap(models, at, name, tokenLimit));
|
|
}
|
|
// P5 adds daily model leaves, including digit-leading IDs; plain keys keep
|
|
// their existing account-name schema and cumulative account semantics.
|
|
if (Object.hasOwn(value, "keys")) {
|
|
requireObject(value.keys, `${where}.keys`);
|
|
out.keys = Object.fromEntries(Object.entries(value.keys).map(([key, entry]) => {
|
|
const at = `${where}.keys.${key}`;
|
|
if (isDailyModelQuota(key, entry)) return [key, dailyQuota(entry, at)];
|
|
return [requireId(key, `${where}.keys`), tokenLimit(entry, at)];
|
|
}));
|
|
}
|
|
// One quota per account; the business read checks each key names launch.account.
|
|
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
|
|
return out;
|
|
}
|
|
|
|
// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the
|
|
// environment variables of one credential set, joined with "+", so a secret can't pass the check.
|
|
// Which sets exist is the launcher's to check (seat imports business, not the other way).
|
|
const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/;
|
|
export function checkAccount(value, where = "launch.account") {
|
|
requireObject(value, where);
|
|
rejectUnknownKeys(value, ["name", "credential"], where);
|
|
const accountName = requireId(value.name, `${where}.name`);
|
|
if (typeof value.credential !== "string" || (value.credential !== "proxy" && !CREDENTIAL.test(value.credential))) {
|
|
refuse(`${where}.credential must name environment variables, joined with "+"`);
|
|
}
|
|
return { name: accountName, credential: value.credential };
|
|
}
|
|
|
|
export function checkLimits(value = {}, where = "launch.limits") {
|
|
requireObject(value, where);
|
|
rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where);
|
|
const out = {};
|
|
if (Object.hasOwn(value, "total")) out.total = count(value.total, `${where}.total`);
|
|
if (Object.hasOwn(value, "providers")) {
|
|
requireObject(value.providers, `${where}.providers`);
|
|
out.providers = Object.fromEntries(Object.entries(value.providers).map(([provider, limit]) => {
|
|
return [name(provider, where), count(limit, `${where}.providers.${provider}`)];
|
|
}));
|
|
}
|
|
if (Object.hasOwn(value, "models")) {
|
|
requireObject(value.models, `${where}.models`);
|
|
out.models = Object.fromEntries(Object.entries(value.models).map(([provider, models]) => {
|
|
name(provider, where);
|
|
requireObject(models, where);
|
|
return [provider, Object.fromEntries(Object.entries(models).map(([model, limit]) => {
|
|
return [name(model, where), count(limit, `${where}.models.${provider}.${model}`)];
|
|
}))];
|
|
}));
|
|
}
|
|
if (Object.hasOwn(value, "tokens")) out.tokens = checkTokenLimits(value.tokens, `${where}.tokens`);
|
|
return out;
|
|
}
|
|
|
|
export function usageCounts(sessions, business) {
|
|
const running = sessions.filter(session => session.business === business);
|
|
const providerCounts = new Map();
|
|
const modelCounts = new Map();
|
|
for (const session of running) {
|
|
const { provider, model } = session;
|
|
if (typeof provider !== "string" || typeof model !== "string") {
|
|
refuse("running session has no exact provider/model; reconcile before launching");
|
|
}
|
|
providerCounts.set(provider, (providerCounts.get(provider) ?? 0) + 1);
|
|
if (!modelCounts.has(provider)) modelCounts.set(provider, new Map());
|
|
const models = modelCounts.get(provider);
|
|
models.set(model, (models.get(model) ?? 0) + 1);
|
|
}
|
|
const providers = Object.fromEntries(providerCounts);
|
|
const models = Object.fromEntries([...modelCounts].map(([provider, counts]) => {
|
|
return [provider, Object.fromEntries(counts)];
|
|
}));
|
|
return { total: running.length, providers, models };
|
|
}
|
|
|
|
export function limitHit(sessions, business, limits, selected) {
|
|
const usage = usageCounts(sessions, business);
|
|
const { provider, model } = selected;
|
|
const checks = [
|
|
["total", limits.total, usage.total],
|
|
["provider", own(limits.providers, provider), own(usage.providers, provider) ?? 0],
|
|
["model", own(own(limits.models, provider), model), own(own(usage.models, provider), model) ?? 0],
|
|
];
|
|
for (const [scope, limit, running] of checks) {
|
|
if (limit !== undefined && running >= limit) {
|
|
return {
|
|
scope, limit, running,
|
|
...(scope !== "total" ? { provider } : {}),
|
|
...(scope === "model" ? { model } : {}),
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|