import { type AccountPoolState, type AccountSlot, type MigrationPolicy } from "./accounts.js"; /** * Account placement. * * The scarce resource is not quota alone but the upstream prompt-prefix cache: * moving a conversation to another account makes its cache cold, which can cost * far more than the quota it saves. So placement is decided once per * conversation and then held; quota only steers the *initial* choice, when the * cache is cold anyway and switching is free. */ export type SchedulingMode = "cache-first" | "balanced" | "spread"; export declare const DEFAULT_SCHEDULING_MODE: SchedulingMode; /** Remaining headroom per account, 0..1. Absent entries are treated as unknown. */ export type UsageSnapshot = Record; /** * Stable fingerprint for a conversation. * * Only the first user message is hashed: model id, timestamps and later turns * are deliberately excluded so the key never drifts as the conversation grows. * A drifting key would rebind mid-conversation and throw away the cache. */ export declare function conversationKey(firstUserMessage: string | undefined, fallback?: string): string; export interface ConversationKeyInput { /** senpi's per-session id (`SimpleStreamOptions.sessionId`), when available. */ sessionId?: string | undefined; firstUserMessage?: string | undefined; fallback?: string | undefined; } /** * Preferred fingerprint: the session id. * * The first user message is a proxy for "which conversation is this", but it is * not actually stable: once senpi compacts, the summary becomes the first user * message and the content-derived key changes mid-conversation, dropping the * binding and re-placing a conversation whose cache is warm. The session id * does not move, so it anchors the binding for the whole session. Content * hashing remains the fallback for callers that have no session id. */ export declare function conversationKeyFor(input: ConversationKeyInput): string; /** * Rendezvous (HRW) ordering. Adding or removing an account only moves the * conversations that actually hashed to it, leaving every other binding — and * therefore every other warm cache — untouched. */ export declare function rendezvousOrder(key: string, accounts: readonly AccountSlot[]): AccountSlot[]; export interface PlacementOptions { /** Conversation fingerprint; see {@link conversationKey}. */ key: string; mode?: SchedulingMode; usage?: UsageSnapshot; now?: number; random?: () => number; /** Overrides the pool's stored policy; see {@link MigrationPolicy}. */ migration?: MigrationPolicy; } /** * Why this request landed where it did. * * `detour` and `permanent-rebind` are deliberately distinct: a detour is a * wall-clock block that expires, so the warm cache is still worth returning to, * while a permanent rebind means the bound account is gone and the cache with * it. Only the irreversible case is worth telling the user about. */ export type PlacementKind = "pinned" | "affinity-hit" | "cold" | "detour" | "permanent-rebind" | "spread"; export interface Placement { account: AccountSlot; /** Pool state to persist (binding recorded, cursor advanced). */ state: AccountPoolState; /** True when this request reused an existing warm binding. */ reusedBinding: boolean; /** How this account was chosen; see {@link PlacementKind}. */ placement: PlacementKind; /** * The account this conversation permanently left, present only on a * `permanent-rebind` under the `ask` policy. `auto` leaves it unset so the * silent path cannot accidentally notify. */ migratedFrom?: string; } /** * Raised when the `never` migration policy forbids moving a conversation off an * account that has left the pool. Failing the request is the point: it keeps the * conversation and its prompt cache on one account, at the cost of this turn. */ export declare class PermanentRebindRefused extends Error { readonly boundAccount: string; constructor(boundAccount: string); } /** * Choose the account for a request. * * Precedence: an explicit pin always wins; then an existing conversation * binding (the warm cache); then, only for a conversation we have not placed * before, the scheduling mode decides. */ export declare function placeRequest(state: AccountPoolState, options: PlacementOptions): Placement; /** Drop a conversation's binding so the next request is placed afresh. */ export declare function releaseBinding(state: AccountPoolState, key: string): AccountPoolState;