fix(auth): billing backoff + cooldown UX

This commit is contained in:
Peter Steinberger
2026-01-09 21:57:52 +01:00
parent 42a0089b3b
commit c27b1441f7
16 changed files with 497 additions and 43 deletions

View File

@@ -11,6 +11,7 @@ import {
ensureAuthProfileStore,
repairOAuthProfileIdMismatch,
resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
@@ -81,6 +82,32 @@ export async function noteAuthProfileHealth(params: {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
const unusable = (() => {
const now = Date.now();
const out: string[] = [];
for (const profileId of Object.keys(store.usageStats ?? {})) {
const until = resolveProfileUnusableUntilForDisplay(store, profileId);
if (!until || now >= until) continue;
const stats = store.usageStats?.[profileId];
const remaining = formatRemainingShort(until - now);
const kind =
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
: "cooldown";
const hint = kind.startsWith("disabled:billing")
? "Top up credits (provider billing) or switch provider."
: "Wait for cooldown or switch provider.";
out.push(
`- ${profileId}: ${kind} (${remaining})${hint ? `${hint}` : ""}`,
);
}
return out;
})();
if (unusable.length > 0) {
note(unusable.join("\n"), "Auth profile cooldowns");
}
let summary = buildAuthHealthSummary({
store,
cfg: params.cfg,

View File

@@ -13,6 +13,7 @@ const resolveAuthProfileDisplayLabel = vi.fn(
const resolveAuthStorePathForDisplay = vi
.fn()
.mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json");
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const discoverAuthStorage = vi.fn().mockReturnValue({});
@@ -36,6 +37,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
}));
vi.mock("../agents/model-auth.js", () => ({

View File

@@ -18,6 +18,7 @@ import {
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
@@ -174,15 +175,36 @@ function resolveProviderAuthOverview(params: {
modelsPath: string;
}): ProviderAuthOverview {
const { provider, cfg, store } = params;
const now = Date.now();
const profiles = listProfilesForProvider(store, provider);
const withUnusableSuffix = (base: string, profileId: string) => {
const unusableUntil = resolveProfileUnusableUntilForDisplay(
store,
profileId,
);
if (!unusableUntil || now >= unusableUntil) return base;
const stats = store.usageStats?.[profileId];
const kind =
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
: "cooldown";
const remaining = formatRemainingShort(unusableUntil - now);
return `${base} [${kind} ${remaining}]`;
};
const labels = profiles.map((profileId) => {
const profile = store.profiles[profileId];
if (!profile) return `${profileId}=missing`;
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
return withUnusableSuffix(
`${profileId}=${maskApiKey(profile.key)}`,
profileId,
);
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
return withUnusableSuffix(
`${profileId}=token:${maskApiKey(profile.token)}`,
profileId,
);
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
@@ -191,7 +213,8 @@ function resolveProviderAuthOverview(params: {
: display.startsWith(profileId)
? display.slice(profileId.length).trim()
: `(${display})`;
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
const base = `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
return withUnusableSuffix(base, profileId);
});
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
@@ -770,6 +793,39 @@ export async function modelsStatusCommand(
(profile) => profile.type === "oauth" || profile.type === "token",
);
const unusableProfiles = (() => {
const now = Date.now();
const out: Array<{
profileId: string;
provider?: string;
kind: "cooldown" | "disabled";
reason?: string;
until: number;
remainingMs: number;
}> = [];
for (const profileId of Object.keys(store.usageStats ?? {})) {
const unusableUntil = resolveProfileUnusableUntilForDisplay(
store,
profileId,
);
if (!unusableUntil || now >= unusableUntil) continue;
const stats = store.usageStats?.[profileId];
const kind =
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
? "disabled"
: "cooldown";
out.push({
profileId,
provider: store.profiles[profileId]?.provider,
kind,
reason: stats?.disabledReason,
until: unusableUntil,
remainingMs: unusableUntil - now,
});
}
return out.sort((a, b) => a.remainingMs - b.remainingMs);
})();
const checkStatus = (() => {
const hasExpiredOrMissing =
oauthProfiles.some((profile) =>
@@ -805,6 +861,7 @@ export async function modelsStatusCommand(
providersWithOAuth: providersWithOauth,
missingProvidersInUse,
providers: providerAuth,
unusableProfiles,
oauth: {
warnAfterMs: authHealth.warnAfterMs,
profiles: authHealth.profiles,