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: 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: 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 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. - 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 - 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 - 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 { import {
ensureAuthProfileStore, ensureAuthProfileStore,
resolveAuthProfileDisplayLabel, resolveAuthProfileDisplayLabel,
@@ -16,6 +20,7 @@ import {
} from "../../agents/pi-embedded.js"; } from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath, resolveSessionFilePath,
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
@@ -134,6 +139,10 @@ export async function buildStatusReply(params: {
); );
return undefined; return undefined;
} }
const statusAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
let usageLine: string | null = null; let usageLine: string | null = null;
try { try {
const usageProvider = resolveUsageProviderId(provider); const usageProvider = resolveUsageProviderId(provider);
@@ -141,6 +150,7 @@ export async function buildStatusReply(params: {
const usageSummary = await loadProviderUsageSummary({ const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500, timeoutMs: 3500,
providers: [usageProvider], providers: [usageProvider],
agentDir: statusAgentDir,
}); });
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
} }
@@ -185,7 +195,12 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel, resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel, resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel, resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), modelAuth: resolveModelAuthLabel(
provider,
cfg,
sessionEntry,
statusAgentDir,
),
usageLine: usageLine ?? undefined, usageLine: usageLine ?? undefined,
queue: { queue: {
mode: queueSettings.mode, mode: queueSettings.mode,
@@ -213,12 +228,15 @@ function resolveModelAuthLabel(
provider?: string, provider?: string,
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
sessionEntry?: SessionEntry, sessionEntry?: SessionEntry,
agentDir?: string,
): string | undefined { ): string | undefined {
const resolved = provider?.trim(); const resolved = provider?.trim();
if (!resolved) return undefined; if (!resolved) return undefined;
const providerKey = normalizeProviderId(resolved); const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const profileOverride = sessionEntry?.authProfileOverride?.trim(); const profileOverride = sessionEntry?.authProfileOverride?.trim();
const order = resolveAuthProfileOrder({ const order = resolveAuthProfileOrder({
cfg, 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 { describe, expect, it, vi } from "vitest";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { import {
formatUsageReportLines, formatUsageReportLines,
formatUsageSummaryLine, formatUsageSummaryLine,
@@ -66,6 +73,45 @@ describe("provider usage formatting", () => {
}); });
describe("provider usage loading", () => { 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 () => { it("loads usage snapshots with injected auth", async () => {
const makeResponse = (status: number, body: unknown): Response => { const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body); const payload = typeof body === "string" ? body : JSON.stringify(body);
@@ -127,4 +173,95 @@ describe("provider usage loading", () => {
expect(zai?.plan).toBe("Pro"); expect(zai?.plan).toBe("Pro");
expect(mockFetch).toHaveBeenCalled(); 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; timeoutMs?: number;
providers?: UsageProviderId[]; providers?: UsageProviderId[];
auth?: ProviderAuth[]; auth?: ProviderAuth[];
agentDir?: string;
fetch?: typeof fetch; fetch?: typeof fetch;
}; };
@@ -670,9 +671,12 @@ function resolveZaiApiKey(): string | undefined {
async function resolveOAuthToken(params: { async function resolveOAuthToken(params: {
provider: UsageProviderId; provider: UsageProviderId;
agentDir?: string;
}): Promise<ProviderAuth | null> { }): Promise<ProviderAuth | null> {
const cfg = loadConfig(); const cfg = loadConfig();
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const order = resolveAuthProfileOrder({ const order = resolveAuthProfileOrder({
cfg, cfg,
store, store,
@@ -681,12 +685,15 @@ async function resolveOAuthToken(params: {
for (const profileId of order) { for (const profileId of order) {
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
if (!cred || cred.type !== "oauth") continue; if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
try { try {
const resolved = await resolveApiKeyForProfile({ 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, store,
profileId, profileId,
agentDir: params.agentDir,
}); });
if (!resolved?.apiKey) continue; if (!resolved?.apiKey) continue;
let token = resolved.apiKey; let token = resolved.apiKey;
@@ -711,15 +718,20 @@ async function resolveOAuthToken(params: {
return null; return null;
} }
function resolveOAuthProviders(): UsageProviderId[] { function resolveOAuthProviders(agentDir?: string): UsageProviderId[] {
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const cfg = loadConfig(); const cfg = loadConfig();
const providers = usageProviders.filter((provider) => provider !== "zai"); const providers = usageProviders.filter((provider) => provider !== "zai");
return providers.filter((provider) => { const isOAuthLikeCredential = (id: string) => {
const profiles = listProfilesForProvider(store, provider).filter((id) => {
const cred = store.profiles[id]; const cred = store.profiles[id];
return cred?.type === "oauth"; return cred?.type === "oauth" || cred?.type === "token";
}); };
return providers.filter((provider) => {
const profiles = listProfilesForProvider(store, provider).filter(
isOAuthLikeCredential,
);
if (profiles.length > 0) return true; if (profiles.length > 0) return true;
const normalized = normalizeProviderId(provider); const normalized = normalizeProviderId(provider);
const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {}) const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {})
@@ -727,7 +739,7 @@ function resolveOAuthProviders(): UsageProviderId[] {
([, profile]) => normalizeProviderId(profile.provider) === normalized, ([, profile]) => normalizeProviderId(profile.provider) === normalized,
) )
.map(([id]) => id) .map(([id]) => id)
.filter((id) => store.profiles[id]?.type === "oauth"); .filter(isOAuthLikeCredential);
return configuredProfiles.length > 0; return configuredProfiles.length > 0;
}); });
} }
@@ -738,7 +750,7 @@ async function resolveProviderAuths(
if (opts.auth) return opts.auth; if (opts.auth) return opts.auth;
const targetProviders = opts.providers ?? usageProviders; const targetProviders = opts.providers ?? usageProviders;
const oauthProviders = resolveOAuthProviders(); const oauthProviders = resolveOAuthProviders(opts.agentDir);
const auths: ProviderAuth[] = []; const auths: ProviderAuth[] = [];
for (const provider of targetProviders) { for (const provider of targetProviders) {
@@ -749,7 +761,7 @@ async function resolveProviderAuths(
} }
if (!oauthProviders.includes(provider)) continue; if (!oauthProviders.includes(provider)) continue;
const auth = await resolveOAuthToken({ provider }); const auth = await resolveOAuthToken({ provider, agentDir: opts.agentDir });
if (auth) auths.push(auth); if (auth) auths.push(auth);
} }