diff --git a/lib/codex-manager/commands/best.ts b/lib/codex-manager/commands/best.ts index ddba0d7ec..075c33a48 100644 --- a/lib/codex-manager/commands/best.ts +++ b/lib/codex-manager/commands/best.ts @@ -1,6 +1,10 @@ import type { ForecastAccountResult } from "../../forecast.js"; import { type CodexQuotaSnapshot, describeCodexProbeFailure } from "../../quota-probe.js"; -import { resolveNormalizedModel } from "../../request/helpers/model-map.js"; +import { + getModelProfile, + type ModelFamily, + resolveNormalizedModel, +} from "../../request/helpers/model-map.js"; import type { AccountStorageV3 } from "../../storage.js"; import type { TokenFailure, TokenResult } from "../../types.js"; import { DEFAULT_LIVE_PROBE_MODEL } from "../quota-cache-helpers.js"; @@ -123,6 +127,8 @@ export interface BestCommandDeps { now: number; refreshFailure?: TokenFailure; liveQuota?: CodexQuotaSnapshot; + family?: ModelFamily; + model?: string | null; }>, ) => ForecastAccountResult[]; recommendForecastAccount: (results: ForecastAccountResult[]) => { @@ -282,6 +288,13 @@ export async function runBestCommand( } } + // Only an explicit --model moves the recommendation off the codex family; + // see the note in the forecast command. `best` exists to pick the account + // for wrapper traffic, which is codex-family. + const forecastFamily = options.modelProvided + ? getModelProfile(probeModel).promptFamily + : undefined; + const forecastModel = options.modelProvided ? probeModel : undefined; const forecastInputs = storage.accounts.map((account, index) => ({ index, account, @@ -289,6 +302,8 @@ export async function runBestCommand( now, refreshFailure: refreshFailures.get(index), liveQuota: liveQuotaByIndex.get(index), + family: forecastFamily, + model: forecastModel, })); const forecastResults = deps.evaluateForecastAccounts(forecastInputs); const recommendation = deps.recommendForecastAccount(forecastResults); diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts index f454cb03e..c16045311 100644 --- a/lib/codex-manager/commands/forecast.ts +++ b/lib/codex-manager/commands/forecast.ts @@ -14,7 +14,12 @@ import { } from "../forecast-report-shared.js"; import type { QuotaCacheData } from "../../quota-cache.js"; import { type CodexQuotaSnapshot, describeCodexProbeFailure } from "../../quota-probe.js"; -import { DEFAULT_PROBE_MODEL, resolveNormalizedModel } from "../../request/helpers/model-map.js"; +import { + DEFAULT_PROBE_MODEL, + getModelProfile, + type ModelFamily, + resolveNormalizedModel, +} from "../../request/helpers/model-map.js"; import { type AccountMetadataV3, type AccountStorageV3 } from "../../storage.js"; import type { TokenFailure, TokenResult } from "../../types.js"; @@ -23,6 +28,11 @@ interface ForecastCliOptions { json: boolean; explain: boolean; model: string; + /** + * Whether --model was actually passed. The default probe model is not a + * codex-family model, so its family must NOT govern a bare invocation. + */ + modelProvided: boolean; runtimeOverlay: boolean; } @@ -85,6 +95,8 @@ export interface ForecastCommandDeps { quotaCache?: QuotaCacheData | null; allAccounts?: readonly AccountMetadataV3[]; runtimeOverlay?: RuntimeForecastOverlay | null; + family?: ModelFamily; + model?: string | null; }>, ) => ForecastAccountResult[]; summarizeForecast: (results: ForecastAccountResult[]) => { @@ -150,6 +162,7 @@ function parseForecastArgs( json: false, explain: false, model: DEFAULT_PROBE_MODEL, + modelProvided: false, runtimeOverlay: true, }; @@ -178,6 +191,7 @@ function parseForecastArgs( return { ok: false, message: "Missing value for --model" }; } options.model = value; + options.modelProvided = true; i += 1; continue; } @@ -187,6 +201,7 @@ function parseForecastArgs( return { ok: false, message: "Missing value for --model" }; } options.model = value; + options.modelProvided = true; continue; } return { ok: false, message: `Unknown option: ${arg}` }; @@ -358,6 +373,18 @@ export async function runForecastCommand( } } + // Only an explicit --model moves the forecast off the codex family. The + // default probe model is gpt-5.6-sol, whose family is gpt-5.2, so keying a + // bare `forecast` on it would evaluate every account against a family no + // wrapper request uses - /codex/responses buckets into codex. + // + // probeModel, not requestedModel: rate-limit records are keyed by the + // normalized model the proxy routes on. Resolved once rather than per + // account: getModelProfile re-parses the model string on every call. + const forecastFamily = options.modelProvided + ? getModelProfile(requestedModel).promptFamily + : undefined; + const forecastModel = options.modelProvided ? probeModel : undefined; const forecastInputs = storage.accounts.map((account, index) => ({ index, account, @@ -368,6 +395,8 @@ export async function runForecastCommand( quotaCache, allAccounts: storage.accounts, runtimeOverlay, + family: forecastFamily, + model: forecastModel, })); const forecastResults = deps.evaluateForecastAccounts(forecastInputs); const summary = deps.summarizeForecast(forecastResults); diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 0ad7d6ff5..014ea02b7 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -46,6 +46,8 @@ interface ReportCliOptions { json: boolean; explain: boolean; model: string; + /** Whether --model was actually passed; see ForecastCliOptions.modelProvided. */ + modelProvided: boolean; maxAccounts?: number; maxProbes?: number; cachedOnly: boolean; @@ -144,6 +146,7 @@ function parseReportArgs(args: string[]): ParsedArgsResult { json: false, explain: false, model: DEFAULT_PROBE_MODEL, + modelProvided: false, cachedOnly: false, }; @@ -172,6 +175,7 @@ function parseReportArgs(args: string[]): ParsedArgsResult { return { ok: false, message: "Missing value for --model" }; } options.model = value; + options.modelProvided = true; i += 1; continue; } @@ -181,6 +185,7 @@ function parseReportArgs(args: string[]): ParsedArgsResult { return { ok: false, message: "Missing value for --model" }; } options.model = value; + options.modelProvided = true; continue; } if (arg === "--max-accounts") { @@ -462,6 +467,15 @@ export async function runReportCommand( } } + // Only an explicit --model moves the report off the codex family; see the + // note in the forecast command. promptFamily is reused from the inspection + // rather than re-resolved per account. + const forecastFamily = options.modelProvided + ? modelInspection.promptFamily + : undefined; + const forecastModel = options.modelProvided + ? modelInspection.normalized + : undefined; const forecastResults = storage ? evaluateForecastAccounts( storage.accounts.map((account, index) => ({ @@ -474,6 +488,8 @@ export async function runReportCommand( quotaCache, allAccounts: storage.accounts, runtimeOverlay: runtimeSnapshot, + family: forecastFamily, + model: forecastModel, })), ) : []; diff --git a/lib/forecast.ts b/lib/forecast.ts index 3e55f3e5f..367d562eb 100644 --- a/lib/forecast.ts +++ b/lib/forecast.ts @@ -10,7 +10,11 @@ import { isQuotaCacheEntryExhausted, quotaUsedPercentIsExhausted, } from "./quota-readiness.js"; -import { getRateLimitResetTimeForFamily } from "./runtime/account-status.js"; +import type { ModelFamily } from "./request/helpers/model-map.js"; +import { + getRateLimitResetTimeForFamily, + getRateLimitResetTimeForModel, +} from "./runtime/account-status.js"; import type { AccountMetadataV3 } from "./storage.js"; import type { TokenFailure } from "./types.js"; @@ -27,6 +31,18 @@ export interface ForecastAccountInput { quotaCache?: QuotaCacheData | null; allAccounts?: readonly AccountMetadataV3[]; runtimeOverlay?: RuntimeForecastOverlay | null; + /** + * Prompt family whose per-family rate-limit records gate this forecast. + * Callers with a model in hand resolve it via getModelProfile; the codex + * default preserves the historical behavior for model-less surfaces. + */ + family?: ModelFamily; + /** + * Normalized model the forecast is about, when the caller has one. Selection + * keys token/concurrency limits under `family:`, so without it a + * sibling model's record would be read as gating this one. + */ + model?: string | null; } export interface RuntimeForecastOverlay { @@ -242,11 +258,18 @@ export function evaluateForecastAccount( appendWaitReason(reasons, "cooldown remaining", remaining); } - const rateLimitResetAt = getRateLimitResetTimeForFamily( - account, - now, - "codex", - ); + // With a model in hand, gate on exactly the keys selection consults for that + // family/model pair, and on the LATEST of them: a sibling model's record does + // not gate this request, and while both the family-wide and model-scoped keys + // are active the account stays skipped until the later one expires. + // + // Without one (status, fix) no model key can be singled out, so keep the + // family-wide union - the conservative answer, and the behavior those + // surfaces have always had. + const forecastFamily = input.family ?? "codex"; + const rateLimitResetAt = input.model + ? getRateLimitResetTimeForModel(account, now, forecastFamily, input.model) + : getRateLimitResetTimeForFamily(account, now, forecastFamily); if (typeof rateLimitResetAt === "number") { const remaining = Math.max(0, rateLimitResetAt - now); waitMs = Math.max(waitMs, remaining); @@ -298,7 +321,11 @@ export function evaluateForecastAccount( // drop the overlay reason when the condition it describes is no longer // active. Each reason validates only against its own backing disk state // ("rate-limited" -> rateLimitResetTimes, "cooling-down" -> coolingDownUntil) - // so we never substitute a misleading reason string. Non-time-bounded + // so we never substitute a misleading reason string. The rate-limited + // cross-check inherits rateLimitResetAt's scope above: a record under one of + // the keys that actually gates this family/model request keeps the reason, + // while a record for another family - or another model in the same family - + // neither sustains it nor gates this request. Non-time-bounded // reasons ("circuit-open", "token-exhausted", "policy-blocked") have no disk // expiry to check and are always applied. const coolingDownActive = diff --git a/lib/runtime/account-status.ts b/lib/runtime/account-status.ts index 267cf663e..badb7ae4f 100644 --- a/lib/runtime/account-status.ts +++ b/lib/runtime/account-status.ts @@ -1,3 +1,4 @@ +import { getQuotaKey } from "../accounts/rate-limits.js"; import type { ModelFamily } from "../prompts/codex.js"; export function resolveActiveIndex( @@ -50,3 +51,43 @@ export function formatRateLimitEntry( if (remaining <= 0) return null; return `resets in ${formatWaitTime(remaining)}`; } + +/** + * When a request for `family`/`model` stops being rate limited: the LATEST + * active bound among exactly the two keys selection consults — the family-wide + * key and `family:` (see `isRateLimitedForFamily`). Null when neither is + * active. + * + * Deliberately narrower and later than `getRateLimitResetTimeForFamily`, whose + * earliest-reset-across-every-`family:*`-key answer feeds wait displays and + * model-less callers: + * + * - narrower, because `markRateLimitedWithReason` keys token/concurrency limits + * under `family:`, and a sibling model's record does not gate this + * request — folding it in reports a delay the runtime proxy would not impose; + * - later, because the account stays skipped while EITHER key is active, so the + * earliest reset understates the wait when both are set. + * + * Requires a model by construction: a caller without one cannot know which + * model key applies and should keep the family-wide union above. + */ +export function getRateLimitResetTimeForModel( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, + model: string, +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let latest: number | null = null; + const consider = (value: number | undefined): void => { + if (typeof value !== "number" || !Number.isFinite(value)) return; + if (value <= now) return; + if (latest === null || value > latest) latest = value; + }; + + consider(times[getQuotaKey(family)]); + consider(times[getQuotaKey(family, model)]); + return latest; +} diff --git a/test/codex-manager-best-command.test.ts b/test/codex-manager-best-command.test.ts index 4f8307e13..1e1498baa 100644 --- a/test/codex-manager-best-command.test.ts +++ b/test/codex-manager-best-command.test.ts @@ -6,6 +6,11 @@ import { } from "../lib/codex-manager/commands/best.js"; import { CodexUnavailableError } from "../lib/errors.js"; import { CODEX_UNAVAILABLE_PROBE_NOTE } from "../lib/quota-probe.js"; +import { DEFAULT_LIVE_PROBE_MODEL } from "../lib/codex-manager/quota-cache-helpers.js"; +import { + getModelProfile, + resolveNormalizedModel, +} from "../lib/request/helpers/model-map.js"; import type { AccountStorageV3 } from "../lib/storage.js"; function createAccount( @@ -137,6 +142,75 @@ describe("runBestCommand", () => { ); }); + it("threads the probe model's family and id into forecast evaluation", async () => { + const evaluateForecastAccounts = vi.fn((inputs) => { + void inputs; + return [ + { + index: 0, + label: "1. best@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: [], + }, + ] as const; + }); + const deps = createDeps({ + evaluateForecastAccounts, + parseBestArgs: vi.fn(() => ({ + ok: true as const, + options: { + live: false, + json: true, + model: DEFAULT_LIVE_PROBE_MODEL, + modelProvided: false, + } satisfies BestCliOptions, + })), + }); + + await expect(runBestCommand(["--json"], deps)).resolves.toBe(0); + const defaulted = evaluateForecastAccounts.mock.calls.at(-1)?.[0] as + | Array<{ family?: string; model?: string | null }> + | undefined; + // `best` picks the account for wrapper traffic, which is codex-family. + // DEFAULT_LIVE_PROBE_MODEL is gpt-5.6-sol, whose family is gpt-5.2, so a + // bare invocation must leave both unset and fall back to codex rather + // than rank accounts against a family no wrapper request uses. + expect(getModelProfile(DEFAULT_LIVE_PROBE_MODEL).promptFamily).not.toBe( + "codex", + ); + expect(defaulted?.[0]?.family).toBeUndefined(); + expect(defaulted?.[0]?.model).toBeUndefined(); + + const explicitDeps = createDeps({ + evaluateForecastAccounts, + parseBestArgs: vi.fn(() => ({ + ok: true as const, + options: { + live: true, + json: true, + // The bare alias, NOT the canonical id: resolveNormalizedModel + // maps it to "gpt-5.6-sol", so this proves the normalized id + // is what reaches evaluation rather than the raw flag value. + model: "gpt-5.6", + modelProvided: true, + } satisfies BestCliOptions, + })), + }); + await expect( + runBestCommand(["--json", "--live", "--model", "gpt-5.6"], explicitDeps), + ).resolves.toBe(0); + const explicit = evaluateForecastAccounts.mock.calls.at(-1)?.[0] as + | Array<{ family?: string; model?: string | null }> + | undefined; + expect(explicit?.[0]?.family).toBe(getModelProfile("gpt-5.6").promptFamily); + expect(resolveNormalizedModel("gpt-5.6")).not.toBe("gpt-5.6"); + expect(explicit?.[0]?.model).toBe(resolveNormalizedModel("gpt-5.6")); + }); + it("emits json output when no accounts are configured", async () => { const deps = createDeps({ loadAccounts: vi.fn(async () => ({ diff --git a/test/codex-manager-forecast-command.test.ts b/test/codex-manager-forecast-command.test.ts index 9846e773d..acba92122 100644 --- a/test/codex-manager-forecast-command.test.ts +++ b/test/codex-manager-forecast-command.test.ts @@ -5,7 +5,11 @@ import { } from "../lib/codex-manager/commands/forecast.js"; import { CodexUnavailableError } from "../lib/errors.js"; import { CODEX_UNAVAILABLE_PROBE_NOTE } from "../lib/quota-probe.js"; -import { DEFAULT_PROBE_MODEL } from "../lib/request/helpers/model-map.js"; +import { + DEFAULT_PROBE_MODEL, + getModelProfile, + resolveNormalizedModel, +} from "../lib/request/helpers/model-map.js"; import type { AccountStorageV3 } from "../lib/storage.js"; function createStorage(): AccountStorageV3 { @@ -169,6 +173,49 @@ describe("runForecastCommand", () => { ); }); + it("threads the requested model's family and normalized id into forecast evaluation", async () => { + const evaluateForecastAccounts = vi.fn((inputs) => { + void inputs; + return [ + { + index: 0, + label: "1. forecast@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: [], + }, + ] as const; + }); + const deps = createDeps({ evaluateForecastAccounts }); + + await expect( + runForecastCommand(["--json", "--model", "gpt-5.6"], deps), + ).resolves.toBe(0); + const explicit = evaluateForecastAccounts.mock.calls.at(-1)?.[0] as + | Array<{ family?: string; model?: string | null }> + | undefined; + expect(explicit?.[0]?.family).toBe(getModelProfile("gpt-5.6").promptFamily); + // The bare alias is driven in deliberately: resolveNormalizedModel maps + // it to "gpt-5.6-sol", so a raw pass-through would fail here. Rate-limit + // records are keyed by the model the proxy routes on, not the flag value. + expect(resolveNormalizedModel("gpt-5.6")).not.toBe("gpt-5.6"); + expect(explicit?.[0]?.model).toBe(resolveNormalizedModel("gpt-5.6")); + + await expect(runForecastCommand(["--json"], deps)).resolves.toBe(0); + const defaulted = evaluateForecastAccounts.mock.calls.at(-1)?.[0] as + | Array<{ family?: string; model?: string | null }> + | undefined; + // DEFAULT_PROBE_MODEL is gpt-5.6-sol, family gpt-5.2 - NOT codex. A bare + // `forecast` must leave family and model unset so evaluation keeps the + // codex default, matching the family /codex/responses buckets into. + expect(getModelProfile(DEFAULT_PROBE_MODEL).promptFamily).not.toBe("codex"); + expect(defaulted?.[0]?.family).toBeUndefined(); + expect(defaulted?.[0]?.model).toBeUndefined(); + }); + it("honors --no-runtime-overlay in json forecast output", async () => { const evaluateForecastAccounts = vi.fn((inputs) => { const overlay = inputs[0]?.runtimeOverlay as diff --git a/test/codex-manager-report-command.test.ts b/test/codex-manager-report-command.test.ts index 3df81a704..4d28d05d3 100644 --- a/test/codex-manager-report-command.test.ts +++ b/test/codex-manager-report-command.test.ts @@ -93,6 +93,149 @@ describe("runReportCommand", () => { expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bogus"); }); + it("gates the forecast on the requested model's family record", async () => { + const storage = createStorage([ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + rateLimitResetTimes: { "gpt-5.2": 31_000 }, + }, + ]); + const deps = createDeps({ loadAccounts: vi.fn(async () => storage) }); + + const readForecast = (): { + accounts: Array<{ availability: string; reasons: string[] }>; + } => + ( + JSON.parse( + String( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? + "{}", + ), + ) as { + forecast: { + accounts: Array<{ availability: string; reasons: string[] }>; + }; + } + ).forecast; + + // The record is under the gpt-5.2 family, which gpt-5.6-sol belongs to. + await expect( + runReportCommand(["--json", "--model", "gpt-5.6-sol"], deps), + ).resolves.toBe(0); + const general = readForecast(); + expect(general.accounts[0]?.availability).toBe("delayed"); + expect( + general.accounts[0]?.reasons.some((reason) => + reason.startsWith("rate limit resets in"), + ), + ).toBe(true); + + // A codex-family model is not gated by that record. + await expect( + runReportCommand(["--json", "--model", "gpt-5.3-codex"], deps), + ).resolves.toBe(0); + const codex = readForecast(); + expect(codex.accounts[0]?.availability).toBe("ready"); + }); + + it("keeps a bare report on the codex family", async () => { + const storage = createStorage([ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + rateLimitResetTimes: { codex: 31_000 }, + }, + ]); + const deps = createDeps({ loadAccounts: vi.fn(async () => storage) }); + + // DEFAULT_PROBE_MODEL is gpt-5.6-sol, whose family is gpt-5.2. Keying the + // no-flag invocation on it would report this account ready while every + // /codex/responses request 503s off the very same record. + await expect(runReportCommand(["--json"], deps)).resolves.toBe(0); + const forecast = ( + JSON.parse( + String( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? + "{}", + ), + ) as { + forecast: { + accounts: Array<{ availability: string; reasons: string[] }>; + }; + } + ).forecast; + expect(forecast.accounts[0]?.availability).toBe("delayed"); + expect( + forecast.accounts[0]?.reasons.some((reason) => + reason.startsWith("rate limit resets in"), + ), + ).toBe(true); + }); + + it("does not gate the forecast on a sibling model's record in the same family", async () => { + const storage = createStorage([ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + // A token/concurrency limit on a sibling model, keyed + // `family:` the way markRateLimitedWithReason writes it. + rateLimitResetTimes: { "gpt-5.2:gpt-5.6-terra": 31_000 }, + }, + ]); + const deps = createDeps({ loadAccounts: vi.fn(async () => storage) }); + + const readForecast = (): { + accounts: Array<{ availability: string; reasons: string[] }>; + } => + ( + JSON.parse( + String( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? + "{}", + ), + ) as { + forecast: { + accounts: Array<{ availability: string; reasons: string[] }>; + }; + } + ).forecast; + + // Selection checks `gpt-5.2` and `gpt-5.2:gpt-5.6-sol`, neither of which + // is set - the proxy serves this model, so the report must not say wait. + await expect( + runReportCommand(["--json", "--model", "gpt-5.6-sol"], deps), + ).resolves.toBe(0); + expect(readForecast().accounts[0]?.availability).toBe("ready"); + + // The model the record actually names is still gated. + await expect( + runReportCommand(["--json", "--model", "gpt-5.6-terra"], deps), + ).resolves.toBe(0); + const own = readForecast(); + expect(own.accounts[0]?.availability).toBe("delayed"); + expect( + own.accounts[0]?.reasons.some((reason) => + reason.startsWith("rate limit resets in"), + ), + ).toBe(true); + }); + it("rejects a flag-like or whitespace-only --model value instead of consuming it", async () => { // Split-arg form trims before validating, so " -x" / " " can't slip // through and silently fall back to the default model. diff --git a/test/forecast.test.ts b/test/forecast.test.ts index 4d858f2f3..a74987fe9 100644 --- a/test/forecast.test.ts +++ b/test/forecast.test.ts @@ -387,6 +387,187 @@ describe("forecast helpers", () => { expect(result.reasons).toContain("runtime skip: rate-limited"); }); + it("gates availability on the requested family's record, not the codex family", () => { + const now = 1_700_000_000_000; + const account = { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { "gpt-5.2": now + 30_000 }, + }; + + const general = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account, + family: "gpt-5.2", + }); + expect(general.availability).toBe("delayed"); + expect(general.waitMs).toBe(30_000); + expect( + general.reasons.some((reason) => reason.startsWith("rate limit resets in")), + ).toBe(true); + + const codex = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account, + family: "codex", + }); + expect(codex.availability).toBe("ready"); + }); + + it("keeps a rate-limited overlay alive when the record matches the requested family", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { "gpt-5.2": now + 30_000 }, + }, + runtimeOverlay: { + lastPoolExhaustionSkipReasons: { "0": "rate-limited" }, + }, + family: "gpt-5.2", + }); + + // Before family threading this overlay was cross-checked against the + // codex family, judged stale, and dropped - the account read "ready" + // while the runtime proxy refused every request for the family. + expect(result.availability).toBe("unavailable"); + expect(result.reasons).toContain("runtime skip: rate-limited"); + }); + + it("drops a rate-limited overlay backed only by another family's record", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { "gpt-5.2": now + 30_000 }, + }, + runtimeOverlay: { + lastPoolExhaustionSkipReasons: { "0": "rate-limited" }, + }, + family: "codex", + }); + + expect(result.availability).toBe("ready"); + }); + + it("ignores a sibling model's record in the requested model's family", () => { + const now = 1_700_000_000_000; + const account = { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + // markRateLimitedWithReason keys token/concurrency limits under + // `family:`. Selection checks only the family-wide key and this + // request's own model key, so a sibling's record must not gate it. + rateLimitResetTimes: { "gpt-5.2:gpt-5.6-terra": now + 30_000 }, + }; + + const sibling = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account, + family: "gpt-5.2", + model: "gpt-5.6-sol", + }); + expect(sibling.availability).toBe("ready"); + expect(sibling.waitMs).toBe(0); + + const own = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account, + family: "gpt-5.2", + model: "gpt-5.6-terra", + }); + expect(own.availability).toBe("delayed"); + expect(own.waitMs).toBe(30_000); + }); + + it("waits for the later of the family-wide and model-scoped resets", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { + "gpt-5.2": now + 5_000, + "gpt-5.2:gpt-5.6-sol": now + 45_000, + }, + }, + family: "gpt-5.2", + model: "gpt-5.6-sol", + }); + + // The account stays skipped while EITHER key is active, so reporting the + // earliest reset would send the caller back before it is selectable. + expect(result.availability).toBe("delayed"); + expect(result.waitMs).toBe(45_000); + }); + + it("drops a rate-limited overlay backed only by a sibling model's record", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { "gpt-5.2:gpt-5.6-terra": now + 30_000 }, + }, + runtimeOverlay: { + lastPoolExhaustionSkipReasons: { "0": "rate-limited" }, + }, + family: "gpt-5.2", + model: "gpt-5.6-sol", + }); + + expect(result.availability).toBe("ready"); + }); + + it("keeps the family-wide union for a model-less caller", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "refresh-1", + addedAt: now - 10_000, + lastUsed: now - 10_000, + rateLimitResetTimes: { "gpt-5.2:gpt-5.6-terra": now + 30_000 }, + }, + family: "gpt-5.2", + }); + + // status and fix pass no model, so no model key can be singled out: they + // keep counting every record in the family, exactly as before. + expect(result.availability).toBe("delayed"); + expect(result.waitMs).toBe(30_000); + }); + it("ignores a stale cooling-down overlay when cooldown has elapsed on disk", () => { const now = 1_700_000_000_000; const result = evaluateForecastAccount({