fix(status): show usage for token auth profiles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user