fix(status): show usage for token auth profiles

This commit is contained in:
Peter Steinberger
2026-01-09 14:34:32 +00:00
parent 1afa48fcdf
commit d17141b859
4 changed files with 182 additions and 14 deletions

View File

@@ -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 `<think>`/`<thinking>` 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

View File

@@ -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,

View File

@@ -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<typeof fetch>,
ReturnType<typeof fetch>
>(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<string, string>;
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;
}
});
});

View File

@@ -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<ProviderAuth | null> {
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);
}