From 8c3cdba21c57b0c6d6dd3520cbea0c6652ace107 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 00:24:31 +0000 Subject: [PATCH] feat: sticky auth profile rotation + usage headers --- CHANGELOG.md | 6 +- docs/cli/models.md | 5 +- docs/concepts/model-failover.md | 11 +++ docs/concepts/usage-tracking.md | 2 +- docs/gateway/configuration-examples.md | 38 ++++++++ docs/tools/slash-commands.md | 2 +- src/agents/tools/session-status-tool.ts | 92 +++++++++++++++--- .../reply/directive-handling.impl.ts | 4 + .../reply/directive-handling.persist.ts | 4 + src/auto-reply/reply/get-reply-run.ts | 96 ++++++++++++++++++- src/auto-reply/reply/model-selection.ts | 2 + src/commands/agent.ts | 2 + src/commands/models/list.status-command.ts | 69 ++++++++++--- src/config/sessions/types.ts | 2 + src/infra/provider-usage.format.ts | 24 ++++- src/infra/provider-usage.ts | 6 +- 16 files changed, 334 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7159bc8..813704b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. - Docs: add Date & Time guide and update prompt/timezone configuration docs. - Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. +- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. @@ -34,8 +35,9 @@ - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. -- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. -- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks). +- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths. +- Security: add `SECURITY.md` reporting policy. +- Channels: add Matrix plugin (external) with docs + onboarding hooks. - Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Docs: expand gateway security hardening guidance and incident response checklist. diff --git a/docs/cli/models.md b/docs/cli/models.md index 7db61e25f..dd2eaae05 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -22,6 +22,10 @@ clawdbot models set clawdbot models scan ``` +`clawdbot models status` shows the resolved default/fallbacks plus an auth overview. +When provider usage snapshots are available, the OAuth/token status section includes +provider usage headers. + ## Aliases + fallbacks ```bash @@ -36,4 +40,3 @@ clawdbot models auth add clawdbot models auth setup-token clawdbot models auth paste-token ``` - diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 685d4c7bf..6d4edaa9e 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order: - **Secondary key:** `usageStats.lastUsed` (oldest first, within each type). - **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry. +### Session stickiness (cache-friendly) + +Clawdbot **pins the chosen auth profile per session** to keep provider caches warm. +It does **not** rotate on every request. The pinned profile is reused until: +- the session is reset (`/new` / `/reset`) +- a compaction completes (compaction count increments) +- the profile is in cooldown/disabled + +Manual selection via `/model …@` sets a **user override** for that session +and is not auto‑rotated until a new session starts. + ### Why OAuth can “look lost” If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index bfdfc4036..cff861d6b 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,7 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available). - `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 9e4bd8701..0aecc034d 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. } ``` +### Anthropic subscription + API key, MiniMax fallback +```json5 +{ + auth: { + profiles: { + "anthropic:subscription": { + provider: "anthropic", + mode: "oauth", + email: "user@example.com" + }, + "anthropic:api": { + provider: "anthropic", + mode: "api_key" + } + }, + order: { + anthropic: ["anthropic:subscription", "anthropic:api"] + } + }, + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + apiKey: "${MINIMAX_API_KEY}" + } + } + }, + agent: { + workspace: "~/clawd", + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["minimax/MiniMax-M2.1"] + } + } +} +``` + ### Work bot (restricted access) ```json5 { diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3e1f01ef2..e6c4aa3ed 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re Text + native (when enabled): - `/help` - `/commands` -- `/status` (show current status; includes a short provider usage/quota line when available) +- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/usage` (alias: `/status`) - `/whoami` (show your sender id; alias: `/id`) diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 612b56c67..86cf5ad0f 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -1,5 +1,6 @@ import { Type } from "@sinclair/typebox"; import { resolveAgentDir } from "../../agents/agent-scope.js"; +import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js"; import { ensureAuthProfileStore, resolveAuthProfileDisplayLabel, @@ -28,9 +29,10 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { - formatUsageSummaryLine, + formatUsageWindowSummary, loadProviderUsageSummary, resolveUsageProviderId, + type UsageProviderId, } from "../../infra/provider-usage.js"; import { buildAgentMainSessionKey, @@ -257,10 +259,14 @@ export function createSessionStatusTool(opts?: { delete nextEntry.providerOverride; delete nextEntry.modelOverride; delete nextEntry.authProfileOverride; + delete nextEntry.authProfileOverrideSource; + delete nextEntry.authProfileOverrideCompactionCount; } else { nextEntry.providerOverride = selection.provider; nextEntry.modelOverride = selection.model; delete nextEntry.authProfileOverride; + delete nextEntry.authProfileOverrideSource; + delete nextEntry.authProfileOverrideCompactionCount; } store[resolved.key] = nextEntry; await updateSessionStore(storePath, (nextStore) => { @@ -277,24 +283,48 @@ export function createSessionStatusTool(opts?: { defaultModel: DEFAULT_MODEL, }); const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider; - const usageProvider = resolveUsageProviderId(providerForCard); - let usageLine: string | undefined; - if (usageProvider) { + const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const authHealth = buildAuthHealthSummary({ + store: authStore, + cfg, + }); + const oauthProfiles = authHealth.profiles.filter( + (profile) => profile.type === "oauth" || profile.type === "token", + ); + + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + const usageByProvider = new Map(); + if (usageProviders.length > 0) { try { const usageSummary = await loadProviderUsageSummary({ timeoutMs: 3500, - providers: [usageProvider], + providers: usageProviders, agentDir, }); - const formatted = formatUsageSummaryLine(usageSummary, { - now: Date.now(), - }); - if (formatted) usageLine = formatted; + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { + now: Date.now(), + maxWindows: 2, + }); + if (formatted) usageByProvider.set(snapshot.provider, formatted); + } } catch { // ignore } } + const usageProvider = resolveUsageProviderId(providerForCard); + const usageLine = + oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider) + ? `📊 Usage: ${usageByProvider.get(usageProvider)}` + : undefined; + const isGroup = resolved.entry.chatType === "group" || resolved.entry.chatType === "room" || @@ -340,13 +370,53 @@ export function createSessionStatusTool(opts?: { includeTranscriptUsage: false, }); + const authStatusLines = (() => { + if (oauthProfiles.length === 0) return []; + const formatStatus = (status: string) => { + if (status === "ok") return "ok"; + if (status === "static") return "static"; + if (status === "expiring") return "expiring"; + if (status === "missing") return "unknown"; + return "expired"; + }; + const profilesByProvider = new Map(); + for (const profile of oauthProfiles) { + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + const lines: string[] = ["OAuth/token status"]; + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? ` — usage: ${usage}` : ""; + lines.push(`- ${provider}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = profile.source !== "store" ? ` (${profile.source})` : ""; + lines.push(` - ${labelText} ${status}${expiry}${source}`); + } + } + return lines; + })(); + + const fullStatusText = + authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText; + return { - content: [{ type: "text", text: statusText }], + content: [{ type: "text", text: fullStatusText }], details: { ok: true, sessionKey: resolved.key, changedModel, - statusText, + statusText: fullStatusText, }, }; }, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 8f1e949ed..18eac8dec 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -264,8 +264,12 @@ export async function handleDirectiveOnly(params: { } if (profileOverride) { sessionEntry.authProfileOverride = profileOverride; + sessionEntry.authProfileOverrideSource = "user"; + delete sessionEntry.authProfileOverrideCompactionCount; } else if (directives.hasModelDirective) { delete sessionEntry.authProfileOverride; + delete sessionEntry.authProfileOverrideSource; + delete sessionEntry.authProfileOverrideCompactionCount; } } if (directives.hasQueueDirective && directives.queueReset) { diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index d2a4d1d0e..8fc4ebc0a 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -156,8 +156,12 @@ export async function persistInlineDirectives(params: { } if (profileOverride) { sessionEntry.authProfileOverride = profileOverride; + sessionEntry.authProfileOverrideSource = "user"; + delete sessionEntry.authProfileOverrideCompactionCount; } else if (directives.hasModelDirective) { delete sessionEntry.authProfileOverride; + delete sessionEntry.authProfileOverrideSource; + delete sessionEntry.authProfileOverrideCompactionCount; } provider = resolved.ref.provider; model = resolved.ref.model; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 0691b7b9b..a2f23d0f7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -5,6 +5,11 @@ import { isEmbeddedPiRunStreaming, resolveEmbeddedSessionLane, } from "../../agents/pi-embedded.js"; +import { + ensureAuthProfileStore, + isProfileInCooldown, + resolveAuthProfileOrder, +} from "../../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveSessionFilePath, @@ -97,6 +102,86 @@ type RunPreparedReplyParams = { abortedLastRun: boolean; }; +async function resolveSessionAuthProfileOverride(params: { + cfg: ClawdbotConfig; + provider: string; + agentDir: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + isNewSession: boolean; +}): Promise { + const { + cfg, + provider, + agentDir, + sessionEntry, + sessionStore, + sessionKey, + storePath, + isNewSession, + } = params; + if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride; + + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const order = resolveAuthProfileOrder({ cfg, store, provider }); + if (order.length === 0) return sessionEntry.authProfileOverride; + + const pickFirstAvailable = () => + order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0]; + const pickNextAvailable = (current: string) => { + const startIndex = order.indexOf(current); + if (startIndex < 0) return pickFirstAvailable(); + for (let offset = 1; offset <= order.length; offset += 1) { + const candidate = order[(startIndex + offset) % order.length]; + if (!isProfileInCooldown(store, candidate)) return candidate; + } + return order[startIndex] ?? order[0]; + }; + + const compactionCount = sessionEntry.compactionCount ?? 0; + const storedCompaction = + typeof sessionEntry.authProfileOverrideCompactionCount === "number" + ? sessionEntry.authProfileOverrideCompactionCount + : compactionCount; + + let current = sessionEntry.authProfileOverride?.trim(); + if (current && !order.includes(current)) current = undefined; + + const source = sessionEntry.authProfileOverrideSource ?? (current ? "user" : undefined); + if (source === "user" && current && !isNewSession) { + return current; + } + + let next = current; + if (isNewSession) { + next = current ? pickNextAvailable(current) : pickFirstAvailable(); + } else if (current && compactionCount > storedCompaction) { + next = pickNextAvailable(current); + } else if (!current || isProfileInCooldown(store, current)) { + next = pickFirstAvailable(); + } + + if (!next) return current; + const shouldPersist = + next !== sessionEntry.authProfileOverride || + sessionEntry.authProfileOverrideSource !== "auto" || + sessionEntry.authProfileOverrideCompactionCount !== compactionCount; + if (shouldPersist) { + sessionEntry.authProfileOverride = next; + sessionEntry.authProfileOverrideSource = "auto"; + sessionEntry.authProfileOverrideCompactionCount = compactionCount; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + + return next; +} + export async function runPreparedReply( params: RunPreparedReplyParams, ): Promise { @@ -314,7 +399,16 @@ export async function runPreparedReply( resolvedQueue.mode === "followup" || resolvedQueue.mode === "collect" || resolvedQueue.mode === "steer-backlog"; - const authProfileId = sessionEntry?.authProfileOverride; + const authProfileId = await resolveSessionAuthProfileOverride({ + cfg, + provider, + agentDir, + sessionEntry, + sessionStore, + sessionKey, + storePath, + isNewSession, + }); const followupRun = { prompt: queuedBody, messageId: sessionCtx.MessageSid, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index b8d8333df..37f742f1a 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -217,6 +217,8 @@ export async function createModelSelectionState(params: { const profile = store.profiles[sessionEntry.authProfileOverride]; if (!profile || profile.provider !== provider) { delete sessionEntry.authProfileOverride; + delete sessionEntry.authProfileOverrideSource; + delete sessionEntry.authProfileOverrideCompactionCount; sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; if (storePath) { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e7d3680dc..42c3f7bf2 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -286,6 +286,8 @@ export async function agentCommand( const profile = store.profiles[authProfileId]; if (!profile || profile.provider !== provider) { delete entry.authProfileOverride; + delete entry.authProfileOverrideSource; + delete entry.authProfileOverrideCompactionCount; entry.updatedAt = Date.now(); if (sessionStore && sessionKey) { sessionStore[sessionKey] = entry; diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index deca0ff2c..3b1d2f860 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -14,6 +14,12 @@ import { resolveEnvApiKey } from "../../agents/model-auth.js"; import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { + formatUsageWindowSummary, + loadProviderUsageSummary, + resolveUsageProviderId, + type UsageProviderId, +} from "../../infra/provider-usage.js"; import type { RuntimeEnv } from "../../runtime.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; @@ -402,6 +408,32 @@ export async function modelsStatusCommand( return; } + const usageByProvider = new Map(); + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + if (usageProviders.length > 0) { + try { + const usageSummary = await loadProviderUsageSummary({ + providers: usageProviders, + agentDir, + timeoutMs: 3500, + }); + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 }); + if (formatted) { + usageByProvider.set(snapshot.provider, formatted); + } + } + } catch { + // ignore usage failures + } + } + const formatStatus = (status: string) => { if (status === "ok") return colorize(rich, theme.success, "ok"); if (status === "static") return colorize(rich, theme.muted, "static"); @@ -410,19 +442,32 @@ export async function modelsStatusCommand( return colorize(rich, theme.error, "expired"); }; + const profilesByProvider = new Map(); for (const profile of oauthProfiles) { - const labelText = profile.label || profile.profileId; - const label = colorize(rich, theme.accent, labelText); - const status = formatStatus(profile.status); - const expiry = - profile.status === "static" - ? "" - : profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(`- ${label} ${status}${expiry}${source}`); + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; + runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; + runtime.log(` - ${label} ${status}${expiry}${source}`); + } } if (opts.check) runtime.exit(checkStatus); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index ab3f87c42..1ca929995 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -33,6 +33,8 @@ export type SessionEntry = { providerOverride?: string; modelOverride?: string; authProfileOverride?: string; + authProfileOverrideSource?: "auto" | "user"; + authProfileOverrideCompactionCount?: number; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index 893219fd9..f5a1b6995 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -1,5 +1,5 @@ import { clampPercent } from "./provider-usage.shared.js"; -import type { UsageSummary, UsageWindow } from "./provider-usage.types.js"; +import type { ProviderUsageSnapshot, UsageSummary, UsageWindow } from "./provider-usage.types.js"; function formatResetRemaining(targetMs?: number, now?: number): string | null { if (!targetMs) return null; @@ -35,6 +35,28 @@ function formatWindowShort(window: UsageWindow, now?: number): string { return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`; } +export function formatUsageWindowSummary( + snapshot: ProviderUsageSnapshot, + opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, +): string | null { + if (snapshot.error) return `error: ${snapshot.error}`; + if (snapshot.windows.length === 0) return null; + const now = opts?.now ?? Date.now(); + const maxWindows = + typeof opts?.maxWindows === "number" && opts.maxWindows > 0 + ? Math.min(opts.maxWindows, snapshot.windows.length) + : snapshot.windows.length; + const includeResets = opts?.includeResets ?? false; + const windows = snapshot.windows.slice(0, maxWindows); + const parts = windows.map((window) => { + const remaining = clampPercent(100 - window.usedPercent); + const reset = includeResets ? formatResetRemaining(window.resetAt, now) : null; + const resetSuffix = reset ? ` ⏱${reset}` : ""; + return `${window.label} ${remaining.toFixed(0)}% left${resetSuffix}`; + }); + return parts.join(" · "); +} + export function formatUsageSummaryLine( summary: UsageSummary, opts?: { now?: number; maxProviders?: number }, diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index b4171acbe..af69b18f9 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -1,4 +1,8 @@ -export { formatUsageReportLines, formatUsageSummaryLine } from "./provider-usage.format.js"; +export { + formatUsageReportLines, + formatUsageSummaryLine, + formatUsageWindowSummary, +} from "./provider-usage.format.js"; export { loadProviderUsageSummary } from "./provider-usage.load.js"; export { resolveUsageProviderId } from "./provider-usage.shared.js"; export type {