diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c68bbd9..22cb342a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). - Status: show active auth profile and key snippet in /status. +- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token). - Agent: promote ``/`` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 1453a4d43..222fb9081 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,3 +1,7 @@ +import { + resolveAgentDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { ensureAuthProfileStore, resolveAuthProfileDisplayLabel, @@ -16,6 +20,7 @@ import { } from "../../agents/pi-embedded.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { + resolveAgentIdFromSessionKey, resolveSessionFilePath, type SessionEntry, type SessionScope, @@ -134,6 +139,10 @@ export async function buildStatusReply(params: { ); return undefined; } + const statusAgentId = sessionKey + ? resolveAgentIdFromSessionKey(sessionKey) + : resolveDefaultAgentId(cfg); + const statusAgentDir = resolveAgentDir(cfg, statusAgentId); let usageLine: string | null = null; try { const usageProvider = resolveUsageProviderId(provider); @@ -141,6 +150,7 @@ export async function buildStatusReply(params: { const usageSummary = await loadProviderUsageSummary({ timeoutMs: 3500, providers: [usageProvider], + agentDir: statusAgentDir, }); usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); } @@ -185,7 +195,12 @@ export async function buildStatusReply(params: { resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), + modelAuth: resolveModelAuthLabel( + provider, + cfg, + sessionEntry, + statusAgentDir, + ), usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, @@ -213,12 +228,15 @@ function resolveModelAuthLabel( provider?: string, cfg?: ClawdbotConfig, sessionEntry?: SessionEntry, + agentDir?: string, ): string | undefined { const resolved = provider?.trim(); if (!resolved) return undefined; const providerKey = normalizeProviderId(resolved); - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const profileOverride = sessionEntry?.authProfileOverride?.trim(); const order = resolveAuthProfileOrder({ cfg, diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index bb050e463..6e9bef7a9 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -1,4 +1,11 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../agents/auth-profiles.js"; import { formatUsageReportLines, formatUsageSummaryLine, @@ -66,6 +73,45 @@ describe("provider usage formatting", () => { }); describe("provider usage loading", () => { + const HOME_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + ] as const; + type HomeEnvSnapshot = Record< + (typeof HOME_ENV_KEYS)[number], + string | undefined + >; + + const snapshotHomeEnv = (): HomeEnvSnapshot => ({ + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + }); + + const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { + for (const key of HOME_ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; + + const setTempHome = (tempHome: string) => { + process.env.HOME = tempHome; + if (process.platform === "win32") { + process.env.USERPROFILE = tempHome; + const root = path.parse(tempHome).root; + process.env.HOMEDRIVE = root.replace(/\\$/, ""); + process.env.HOMEPATH = tempHome.slice(root.length - 1); + } + }; + it("loads usage snapshots with injected auth", async () => { const makeResponse = (status: number, body: unknown): Response => { const payload = typeof body === "string" ? body : JSON.stringify(body); @@ -127,4 +173,95 @@ describe("provider usage loading", () => { expect(zai?.plan).toBe("Pro"); expect(mockFetch).toHaveBeenCalled(); }); + + it("discovers Claude usage from token auth profiles", async () => { + const homeSnapshot = snapshotHomeEnv(); + const stateSnapshot = process.env.CLAWDBOT_STATE_DIR; + const tempHome = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-provider-usage-"), + ); + try { + setTempHome(tempHome); + process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); + const agentDir = path.join( + process.env.CLAWDBOT_STATE_DIR, + "agents", + "main", + "agent", + ); + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + order: { anthropic: ["anthropic:default"] }, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "token-1", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + expect(listProfilesForProvider(store, "anthropic")).toContain( + "anthropic:default", + ); + + const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = + typeof body === "string" + ? undefined + : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn< + Parameters, + ReturnType + >(async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer token-1"); + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + providers: ["anthropic"], + agentDir, + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(1); + const claude = summary.providers[0]; + expect(claude?.provider).toBe("anthropic"); + expect(claude?.windows[0]?.label).toBe("5h"); + expect(mockFetch).toHaveBeenCalled(); + } finally { + restoreHomeEnv(homeSnapshot); + if (stateSnapshot === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = stateSnapshot; + } + }); }); diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index bc3e220a3..c5bc53a4f 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -106,6 +106,7 @@ type UsageSummaryOptions = { timeoutMs?: number; providers?: UsageProviderId[]; auth?: ProviderAuth[]; + agentDir?: string; fetch?: typeof fetch; }; @@ -670,9 +671,12 @@ function resolveZaiApiKey(): string | undefined { async function resolveOAuthToken(params: { provider: UsageProviderId; + agentDir?: string; }): Promise { const cfg = loadConfig(); - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); const order = resolveAuthProfileOrder({ cfg, store, @@ -681,12 +685,15 @@ async function resolveOAuthToken(params: { for (const profileId of order) { const cred = store.profiles[profileId]; - if (!cred || cred.type !== "oauth") continue; + if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue; try { const resolved = await resolveApiKeyForProfile({ - cfg, + // Usage snapshots should work even if config profile metadata is stale. + // (e.g. config says api_key but the store has a token profile.) + cfg: undefined, store, profileId, + agentDir: params.agentDir, }); if (!resolved?.apiKey) continue; let token = resolved.apiKey; @@ -711,15 +718,20 @@ async function resolveOAuthToken(params: { return null; } -function resolveOAuthProviders(): UsageProviderId[] { - const store = ensureAuthProfileStore(); +function resolveOAuthProviders(agentDir?: string): UsageProviderId[] { + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const cfg = loadConfig(); const providers = usageProviders.filter((provider) => provider !== "zai"); + const isOAuthLikeCredential = (id: string) => { + const cred = store.profiles[id]; + return cred?.type === "oauth" || cred?.type === "token"; + }; return providers.filter((provider) => { - const profiles = listProfilesForProvider(store, provider).filter((id) => { - const cred = store.profiles[id]; - return cred?.type === "oauth"; - }); + const profiles = listProfilesForProvider(store, provider).filter( + isOAuthLikeCredential, + ); if (profiles.length > 0) return true; const normalized = normalizeProviderId(provider); const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {}) @@ -727,7 +739,7 @@ function resolveOAuthProviders(): UsageProviderId[] { ([, profile]) => normalizeProviderId(profile.provider) === normalized, ) .map(([id]) => id) - .filter((id) => store.profiles[id]?.type === "oauth"); + .filter(isOAuthLikeCredential); return configuredProfiles.length > 0; }); } @@ -738,7 +750,7 @@ async function resolveProviderAuths( if (opts.auth) return opts.auth; const targetProviders = opts.providers ?? usageProviders; - const oauthProviders = resolveOAuthProviders(); + const oauthProviders = resolveOAuthProviders(opts.agentDir); const auths: ProviderAuth[] = []; for (const provider of targetProviders) { @@ -749,7 +761,7 @@ async function resolveProviderAuths( } if (!oauthProviders.includes(provider)) continue; - const auth = await resolveOAuthToken({ provider }); + const auth = await resolveOAuthToken({ provider, agentDir: opts.agentDir }); if (auth) auths.push(auth); }