import { type AccountModelUsage } from './model-preference.js'; /** * The one decision: what runs next, on which account, on which model. * * This used to be two decisions that disagreed with each other. Rotation asked * "which account still has room on this model" while the session separately * asked "this model ran out here, what is the next model", and the second one * fired first. So one account running out of Fable moved the whole run to Opus * even though another account had most of its Fable week left, and once that * happened nothing ever moved it back: the choice was remembered for the run, * not re-made per account. Measured on a real machine: ten rotations in six * minutes, ending on an account with 24% of its Fable still free, running Opus. * * One planner, one answer, and the operator picks the rule it follows: * * model-first Use up the CURRENT MODEL everywhere before changing model. * Fable on every account, then Opus on every account. This is * the default, and it is what "stay on Fable as long as * possible" means. * * account-first Use up each ACCOUNT before moving to the next one. Fable * then Opus on this account, then the same on the next. For * when accounts are the scarce thing rather than models. * * Never falling back at all is expressed by a one-model chain * (`modelPreference: ['fable']`), which needs no special case here: the chain * simply runs out, and running out is reported honestly rather than papered * over with a model nobody asked for. */ export type RotationStrategy = 'model-first' | 'account-first'; export interface RotationPlanInput { /** * Accounts in the order rotation would otherwise try them: priority order * with the pinned account first, already filtered for enabled/logged-in/ * account-wide-capped. Usage is per model, expired windows already dropped. */ candidates: AccountModelUsage[]; /** The model the session is running, or null when nothing pins one. */ modelInUse: string | null; /** The fallback chain, in the operator's order. */ preference: readonly string[]; strategy: RotationStrategy; /** * "account|model" pairs proven spent during THIS run. * * Per account, deliberately. Held globally, one account's spent Fable read * as every account's spent Fable, which is the bug this file exists to end. */ spentThisRun: ReadonlySet; } export type RotationPlan = { kind: 'run'; account: string; /** Null means "whatever the session was already running"; nothing is imposed. */ model: string | null; changedModel: boolean; /** Plain-language reason, for the log and for the operator. */ reason: string; } | { kind: 'exhausted'; reason: string; }; /** The key a spent (account, model) pair is remembered under. */ export declare function spentKey(account: string, model: string): string; /** * Where to go next. * * Returns the FIRST usable pairing under the chosen rule, so "first" is where * the policy lives and the two strategies differ only in which loop is outer. */ export declare function planRotation(input: RotationPlanInput): RotationPlan;