From 7af29b47ccb52e1dd13f187cff1496bc4459dbac Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 16 Aug 2026 23:50:52 +0000 Subject: [PATCH] fix(ai): honored Fable/Mythos tier usage in reserve health usageReservePct scoped limits through scopeClaudeLimitsForModelHardBlock, which drops a Fable/Mythos weekly tier row until confirmed exhaustion (>=100% or server exhausted). That guard is correct for credential-wide hard blocks but wrong for the opt-in, non-destructive reserve fallback: a tier row at 96% was removed before reserve health, so the model stayed healthy and kept serving past the configured margin. Added a scopeLimitsForReserve strategy hook (falls back to scopeLimits) and pointed the Claude strategy at scopeClaudeLimitsForModel, so reserve health honors the mapped tier row while credential hard blocks and all other providers are unchanged. Fixes #8773 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/auth-storage.ts | 7 +- packages/ai/src/usage.ts | 10 ++ packages/ai/src/usage/claude.ts | 11 +- .../auth-storage-model-usage-health.test.ts | 106 ++++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 86d99d599..efb1d1816 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `retry.usageReservePct` (Reserve Margin) ignoring Claude Fable/Mythos weekly tier usage until it hit 100%, so a Fable model kept serving turns past the configured reserve; reserve health now honors the mapped tier row while credential-wide hard blocks still require confirmed exhaustion ([#8773](https://github.com/can1357/oh-my-pi/issues/8773)). + ## [17.3.5] - 2026-08-16 ### Added diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 82441706b..bd31cd834 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3961,7 +3961,12 @@ export class AuthStorage { } if (!report) return { credentialId: entry.id, credentialType, state: "unknown" }; - const limits = this.#getScopedUsageLimits(strategy, report, rankingContext); + // Reserve health is opt-in and non-destructive: prefer the strategy's + // reserve scoping, which may expose mapped model/tier rows that the + // hard-block scoper withholds until confirmed exhaustion. + const limits = + strategy.scopeLimitsForReserve?.(report, rankingContext) ?? + this.#getScopedUsageLimits(strategy, report, rankingContext); if (limits.length === 0) return { credentialId: entry.id, credentialType, state: "unknown" }; const currentLimits = limits.filter(limit => { diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 2cd7b0f50..1033a11f9 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -365,6 +365,16 @@ export interface CredentialRankingStrategy { * account-wide quotas can omit this and use all limits. */ scopeLimits?(report: UsageReport, context?: CredentialRankingContext): UsageLimit[]; + /** + * Restrict limits for the opt-in, non-destructive usage-reserve health + * check ({@link AuthStorage.getModelUsageHealth}). Distinct from + * {@link scopeLimits}, which gates credential-wide hard blocks: a provider + * whose model/tier counters are trusted only at confirmed exhaustion for + * hard-blocking can still expose them here so the reserve margin protects + * the mapped quota before it hits the cap. Falls back to {@link scopeLimits} + * when omitted. + */ + scopeLimitsForReserve?(report: UsageReport, context?: CredentialRankingContext): UsageLimit[]; /** * Return a provider-local backoff scope for the requested model. Providers * with backend-specific quotas use this so one exhausted model family does diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 4957f3095..e474fca6b 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -713,7 +713,9 @@ function getClaudeModelKind(context: CredentialRankingContext | undefined): Clau * Claude model-scoped rows are only relevant to the matching model family. * Credential-wide exhaustion checks stay on shared umbrella windows unless the * request model parses to a concrete Anthropic kind, preventing a Fable cap from - * suppressing unrelated Opus/Sonnet traffic. + * suppressing unrelated Opus/Sonnet traffic. Feeds ranking pressure and the + * opt-in reserve-health scope (`scopeLimitsForReserve`); credential-wide hard + * blocks use {@link scopeClaudeLimitsForModelHardBlock} instead. */ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[] { const kind = getClaudeModelKind(context); @@ -742,7 +744,7 @@ function isConfirmedExhaustedTierRow(limit: UsageLimit, nowMs: number): boolean * weekly caps participate only when {@link isConfirmedExhaustedTierRow} * confirms them, so a confirmed-dead account is skipped up front and a * reactive 429 block extends to the tier reset in markUsageLimitReached, - * while unconfirmed rows remain ranking pressure only via + * while unconfirmed rows remain ranking pressure and opt-in reserve health via * scopeClaudeLimitsForModel. */ function scopeClaudeLimitsForModelHardBlock( @@ -810,6 +812,11 @@ export const claudeRankingStrategy: CredentialRankingStrategy = { return { primary, secondary }; }, scopeLimits: scopeClaudeLimitsForModelHardBlock, + // Reserve health is a non-destructive fallback, not a credential hard + // block, so it trusts the mapped tier row before confirmed exhaustion: a + // Fable/Mythos weekly cap inside the reserve margin should move the turn to + // a healthy candidate rather than serve until 100%. + scopeLimitsForReserve: scopeClaudeLimitsForModel, /** * Fable/Mythos usage-limit errors map to tier-local weekly counters. Scope * reactive backoff blocks for those tiers, mirroring the per-counter diff --git a/packages/ai/test/auth-storage-model-usage-health.test.ts b/packages/ai/test/auth-storage-model-usage-health.test.ts index 84116152f..065194bc2 100644 --- a/packages/ai/test/auth-storage-model-usage-health.test.ts +++ b/packages/ai/test/auth-storage-model-usage-health.test.ts @@ -7,6 +7,7 @@ import { type StoredAuthCredential, } from "@oh-my-pi/pi-ai/auth-storage"; import type { CredentialRankingStrategy, UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import { claudeRankingStrategy } from "@oh-my-pi/pi-ai/usage/claude"; import { logger } from "@oh-my-pi/pi-utils"; interface CacheEntry { @@ -609,3 +610,108 @@ describe("AuthStorage corrupt persisted block store", () => { expect(errorSpy).toHaveBeenCalledTimes(scenarios.length); }); }); + +describe("AuthStorage Claude tier reserve health", () => { + const storages: AuthStorage[] = []; + afterEach(() => { + for (const storage of storages) storage.close(); + storages.length = 0; + }); + + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + const HOUR_MS = 60 * 60 * 1000; + + function claudeLimit(opts: { + id: string; + windowId: "5h" | "7d"; + usedFraction: number; + shared?: boolean; + tier?: string; + exhausted?: boolean; + }): UsageLimit { + const resetsAt = Date.now() + 3 * 24 * 60 * 60 * 1000; + return { + id: opts.id, + label: opts.id, + scope: { + provider: "anthropic", + windowId: opts.windowId, + ...(opts.tier !== undefined ? { tier: opts.tier } : {}), + ...(opts.shared !== undefined ? { shared: opts.shared } : {}), + }, + window: { + id: opts.windowId, + label: opts.windowId, + durationMs: opts.windowId === "5h" ? 5 * HOUR_MS : WEEK_MS, + resetsAt, + }, + amount: { usedFraction: opts.usedFraction, unit: "percent" }, + status: opts.exhausted ? "exhausted" : "ok", + }; + } + + async function createClaudeStorage(reports: Record): Promise { + const storage = new AuthStorage(makeStore([oauthRow(1)]), { + usageProviderResolver: provider => (provider === "anthropic" ? makeUsageProvider(reports) : undefined), + rankingStrategyResolver: provider => (provider === "anthropic" ? claudeRankingStrategy : undefined), + configValueResolver: async value => value, + }); + await storage.reload(); + storages.push(storage); + return storage; + } + + function tieredReport(fableUsedFraction: number, exhausted = false): UsageReport { + return report("account-1", [ + claudeLimit({ id: "anthropic:5h", windowId: "5h", usedFraction: 0.1, shared: true }), + claudeLimit({ id: "anthropic:7d", windowId: "7d", usedFraction: 0.72, shared: true }), + claudeLimit({ + id: "anthropic:7d:fable", + windowId: "7d", + usedFraction: fableUsedFraction, + tier: "fable", + exhausted, + }), + ]); + } + + it("reports reserve when the mapped Fable tier row is inside the reserve margin", async () => { + const storage = await createClaudeStorage({ "account-1": tieredReport(0.96) }); + const health = await storage.getModelUsageHealth("anthropic", { + modelId: "claude-fable-5", + reserveFraction: 0.1, + }); + expect(health.state).toBe("reserve"); + expect(health.accounts[0]?.remainingFraction).toBeCloseTo(0.04); + }); + + it("keeps a Fable model healthy while its tier row stays outside the reserve margin", async () => { + const storage = await createClaudeStorage({ "account-1": tieredReport(0.85) }); + const health = await storage.getModelUsageHealth("anthropic", { + modelId: "claude-fable-5", + reserveFraction: 0.1, + }); + expect(health.state).toBe("healthy"); + expect(health.accounts[0]?.remainingFraction).toBeCloseTo(0.15); + }); + + it("does not let a Fable-only tier row pull unrelated Opus traffic into reserve", async () => { + const storage = await createClaudeStorage({ "account-1": tieredReport(0.96) }); + const health = await storage.getModelUsageHealth("anthropic", { + modelId: "claude-opus-4-8", + reserveFraction: 0.1, + }); + expect(health.state).toBe("healthy"); + expect(health.accounts[0]?.remainingFraction).toBeCloseTo(0.28); + }); + + it("still depletes a Fable model on a confirmed 100% tier row", async () => { + const storage = await createClaudeStorage({ "account-1": tieredReport(1, true) }); + const health = await storage.getModelUsageHealth("anthropic", { + modelId: "claude-fable-5", + reserveFraction: 0.1, + }); + expect(health.state).toBe("depleted"); + expect(health.accounts[0]?.resetsAt).toBeGreaterThan(Date.now()); + }); +});