refactor(coding-agent): share active-account matching across /usage renderers

- Extracted `limitMatchesActiveAccount`/`reportMatchesActiveAccount` into `slash-commands/helpers/active-oauth-account.ts` as the single definition of the report-to-account matching rules, including projectId matching against `limit.scope.projectId`/metadata.
- Dropped the duplicated `ActiveAccountIdentity`/`OAuthAccessResolver` shims, `as unknown` session casts, the dead `getOAuthAccountId` fallback, and the email-vs-scope-accountId comparison from `command-controller.ts` and `usage-report.ts`.
- Replaced the async per-provider `resolveActiveAccountsForReports` map with one synchronous typed `authStorage.getOAuthAccountIdentity()` call per render, gated to the session's current provider.
- Re-exported `OAuthAccountIdentity` from `session/auth-storage.ts` and added `active-oauth-account.test.ts` covering the matching rules.
This commit is contained in:
can1357
2026-06-12 04:23:54 +02:00
parent ef9858fc16
commit b0d2597715
5 changed files with 151 additions and 129 deletions
@@ -36,9 +36,10 @@ import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/c
import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
import type { AuthStorage } from "../../session/auth-storage";
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
import type { NewSessionOptions } from "../../session/session-manager";
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
import { outputMeta } from "../../tools/output-meta";
import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
import { replaceTabs } from "../../tools/render-utils";
@@ -404,10 +405,15 @@ export class CommandController {
}
const availableWidth = Math.max(40, (this.ctx.ui.terminal.columns ?? 100) - 2);
const activeAccounts = await resolveActiveAccountsForReports(this.ctx.session, usageReports);
const currentProvider = this.ctx.session.model?.provider;
const activeAccount = currentProvider
? this.ctx.session.modelRegistry.authStorage.getOAuthAccountIdentity(
currentProvider,
this.ctx.session.sessionId,
)
: undefined;
const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth, provider =>
provider === currentProvider ? activeAccounts.get(provider) : undefined,
provider === currentProvider ? activeAccount : undefined,
);
this.ctx.present([new Spacer(1), new Text(output, 1, 0)]);
}
@@ -1309,22 +1315,18 @@ function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined
return formatDuration(resetsAt - nowMs);
}
function formatActiveAccountLabel(activeAccount: ActiveAccountIdentity | undefined): string | undefined {
return activeAccount?.email ?? activeAccount?.accountId;
}
function formatAccountHeaderRow(
limits: UsageLimit[],
reports: UsageReport[],
nowMs: number,
columnWidth: number,
uiTheme: typeof theme,
activeAccount?: ActiveAccountIdentity,
activeAccount?: OAuthAccountIdentity,
): string[] {
const parts = limits.map((limit, index) => {
const reset = formatResetShort(limit, nowMs);
const report = reports[index];
const active = reportMatchesActiveAccount(report, limit, activeAccount);
const active = report !== undefined && limitMatchesActiveAccount(report, limit, activeAccount);
const label = formatAccountLabel(limit, report, index);
return {
label: active ? `● ${label}` : label,
@@ -1471,7 +1473,7 @@ function renderUsageReports(
uiTheme: typeof theme,
nowMs: number,
availableWidth: number,
resolveActiveAccount?: (provider: string) => ActiveAccountIdentity | undefined,
resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
): string {
const lines: string[] = [];
const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
@@ -1521,7 +1523,7 @@ function renderUsageReports(
}
lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
const activeAccountLabel = formatActiveAccountLabel(activeAccount);
const activeAccountLabel = activeAccount?.email ?? activeAccount?.accountId ?? activeAccount?.projectId;
if (activeAccountLabel) {
lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
}
@@ -1592,61 +1594,3 @@ function renderUsageReports(
return lines.join("\n");
}
type ActiveAccountIdentity = {
accountId?: string;
email?: string;
};
type OAuthAccessResolver = {
getOAuthAccountId?: (provider: string, sessionId?: string) => string | undefined;
getOAuthAccountIdentity?: (provider: string, sessionId?: string) => ActiveAccountIdentity | undefined;
};
function normalizeIdentityValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
function reportMatchesActiveAccount(
report: UsageReport | undefined,
limit: UsageLimit,
activeAccount: ActiveAccountIdentity | undefined,
): boolean {
if (!report || !activeAccount) return false;
const activeAccountId = normalizeIdentityValue(activeAccount.accountId);
const activeEmail = normalizeIdentityValue(activeAccount.email);
const metadata = report.metadata ?? {};
const reportAccountId =
normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id) ?? undefined;
const reportEmail = normalizeIdentityValue(metadata.email);
const scopeAccountId = normalizeIdentityValue(limit.scope.accountId);
return Boolean(
(activeAccountId && (reportAccountId === activeAccountId || scopeAccountId === activeAccountId)) ||
(activeEmail && (reportEmail === activeEmail || scopeAccountId === activeEmail)),
);
}
async function resolveActiveAccountsForReports(
sessionValue: unknown,
reports: UsageReport[],
): Promise<Map<string, ActiveAccountIdentity>> {
const session = sessionValue as {
sessionId?: string;
modelRegistry?: { authStorage?: OAuthAccessResolver };
};
const authStorage = session.modelRegistry?.authStorage;
if (!authStorage) return new Map();
const providers = [...new Set(reports.map(report => report.provider))];
const entries = await Promise.all(
providers.map(provider => {
const identity = authStorage.getOAuthAccountIdentity?.(provider, session.sessionId);
const accountId = identity?.accountId ?? authStorage.getOAuthAccountId?.(provider, session.sessionId);
const activeIdentity: ActiveAccountIdentity = {
...(accountId ? { accountId } : {}),
...(identity?.email ? { email: identity.email } : {}),
};
return [provider, activeIdentity] as const;
}),
);
return new Map(entries.filter(([, identity]) => identity.accountId || identity.email));
}
@@ -12,6 +12,7 @@ export type {
AuthStorageOptions,
CredentialOrigin,
CredentialOriginKind,
OAuthAccountIdentity,
OAuthCredential,
SerializedAuthStorage,
SnapshotResponse,
@@ -0,0 +1,44 @@
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
import type { OAuthAccountIdentity } from "../../session/auth-storage";
function normalizeIdentityValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
/**
* True when a single usage-limit column belongs to the given OAuth identity.
*
* Single definition of the matching rules for both `/usage` renderers:
* - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
* - `email` ↔ report metadata `email`
* - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
* (Google-style providers key usage on the GCP project, not an account id)
*/
export function limitMatchesActiveAccount(
report: UsageReport,
limit: UsageLimit,
identity: OAuthAccountIdentity | undefined,
): boolean {
if (!identity) return false;
const metadata = report.metadata ?? {};
const activeAccountId = normalizeIdentityValue(identity.accountId);
if (activeAccountId) {
const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
if (reportAccountId === activeAccountId) return true;
if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
}
const activeEmail = normalizeIdentityValue(identity.email);
if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
const activeProjectId = normalizeIdentityValue(identity.projectId);
if (activeProjectId) {
if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
}
return false;
}
/** True when any limit column in `report` belongs to the given OAuth identity. */
export function reportMatchesActiveAccount(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
if (!identity) return false;
return report.limits.some(limit => limitMatchesActiveAccount(report, limit, identity));
}
@@ -1,5 +1,7 @@
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
import type { OAuthAccountIdentity } from "../../session/auth-storage";
import type { SlashCommandRuntime } from "../types";
import { reportMatchesActiveAccount } from "./active-oauth-account";
import { formatDuration, renderAsciiBar } from "./format";
function formatProviderName(provider: string): string {
@@ -31,66 +33,10 @@ function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index:
return `account ${index + 1}`;
}
type ActiveAccountIdentity = {
accountId?: string;
email?: string;
};
type OAuthAccessResolver = {
getOAuthAccountId?: (provider: string, sessionId?: string) => string | undefined;
getOAuthAccountIdentity?: (provider: string, sessionId?: string) => ActiveAccountIdentity | undefined;
};
function normalizeIdentityValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
/** True when the report belongs to the given OAuth account identity. */
function isReportForAccount(report: UsageReport, activeAccount: ActiveAccountIdentity): boolean {
const activeAccountId = normalizeIdentityValue(activeAccount.accountId);
const activeEmail = normalizeIdentityValue(activeAccount.email);
const metadata = report.metadata ?? {};
const reportAccountId =
normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id) ?? undefined;
const reportEmail = normalizeIdentityValue(metadata.email);
return report.limits.some(limit => {
const scopeAccountId = normalizeIdentityValue(limit.scope.accountId);
return Boolean(
(activeAccountId && (reportAccountId === activeAccountId || scopeAccountId === activeAccountId)) ||
(activeEmail && (reportEmail === activeEmail || scopeAccountId === activeEmail)),
);
});
}
async function resolveActiveAccountsForReports(
sessionValue: unknown,
reports: UsageReport[],
): Promise<Map<string, ActiveAccountIdentity>> {
const session = sessionValue as {
sessionId?: string;
modelRegistry?: { authStorage?: OAuthAccessResolver };
};
const authStorage = session.modelRegistry?.authStorage;
if (!authStorage) return new Map();
const providers = [...new Set(reports.map(report => report.provider))];
const entries = await Promise.all(
providers.map(provider => {
const identity = authStorage.getOAuthAccountIdentity?.(provider, session.sessionId);
const accountId = identity?.accountId ?? authStorage.getOAuthAccountId?.(provider, session.sessionId);
const activeIdentity: ActiveAccountIdentity = {
...(accountId ? { accountId } : {}),
...(identity?.email ? { email: identity.email } : {}),
};
return [provider, activeIdentity] as const;
}),
);
return new Map(entries.filter(([, identity]) => identity.accountId || identity.email));
}
function renderUsageReports(
reports: UsageReport[],
nowMs: number,
resolveActiveAccount?: (provider: string) => ActiveAccountIdentity | undefined,
resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
): string {
const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`];
@@ -107,7 +53,7 @@ function renderUsageReports(
lines.push("", formatProviderName(provider));
const activeAccount = resolveActiveAccount?.(provider);
for (const report of providerReports) {
const inUse = activeAccount !== undefined && isReportForAccount(report, activeAccount);
const inUse = reportMatchesActiveAccount(report, activeAccount);
if (report.limits.length === 0) {
const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
lines.push(`- ${email}: no limits reported`);
@@ -144,10 +90,15 @@ export async function buildUsageReportText(runtime: SlashCommandRuntime): Promis
if (provider.fetchUsageReports) {
const reports = await provider.fetchUsageReports();
if (reports && reports.length > 0) {
const activeAccounts = await resolveActiveAccountsForReports(runtime.session, reports);
const currentProvider = runtime.session.model?.provider;
const activeAccount = currentProvider
? runtime.session.modelRegistry.authStorage.getOAuthAccountIdentity(
currentProvider,
runtime.session.sessionId,
)
: undefined;
return renderUsageReports(reports, Date.now(), providerId =>
providerId === currentProvider ? activeAccounts.get(providerId) : undefined,
providerId === currentProvider ? activeAccount : undefined,
);
}
}
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
import {
limitMatchesActiveAccount,
reportMatchesActiveAccount,
} from "../src/slash-commands/helpers/active-oauth-account";
function makeLimit(scope: Partial<UsageLimit["scope"]> = {}): UsageLimit {
return {
id: "limit-1",
label: "Requests",
scope: { provider: "anthropic", ...scope },
amount: { usedFraction: 0.5, unit: "percent" },
};
}
function makeReport(overrides: Partial<UsageReport> = {}): UsageReport {
return {
provider: "anthropic",
fetchedAt: Date.now(),
limits: [makeLimit()],
...overrides,
};
}
describe("limitMatchesActiveAccount", () => {
test("matches accountId against report metadata (camel and snake case) and limit scope", () => {
const identity = { accountId: "ACC-1" };
expect(limitMatchesActiveAccount(makeReport({ metadata: { accountId: "acc-1" } }), makeLimit(), identity)).toBe(
true,
);
expect(limitMatchesActiveAccount(makeReport({ metadata: { account_id: "acc-1" } }), makeLimit(), identity)).toBe(
true,
);
expect(limitMatchesActiveAccount(makeReport(), makeLimit({ accountId: "acc-1" }), identity)).toBe(true);
expect(limitMatchesActiveAccount(makeReport({ metadata: { accountId: "acc-2" } }), makeLimit(), identity)).toBe(
false,
);
});
test("matches email against report metadata only — never against scope accountId", () => {
const identity = { email: "user@example.com" };
expect(
limitMatchesActiveAccount(makeReport({ metadata: { email: "User@Example.com" } }), makeLimit(), identity),
).toBe(true);
// An email must not match an opaque account-id slot that happens to hold the same string.
expect(limitMatchesActiveAccount(makeReport(), makeLimit({ accountId: "user@example.com" }), identity)).toBe(
false,
);
});
test("matches projectId for Google-style providers via scope or metadata", () => {
const identity = { projectId: "gcp-proj-1" };
expect(limitMatchesActiveAccount(makeReport(), makeLimit({ projectId: "gcp-proj-1" }), identity)).toBe(true);
expect(
limitMatchesActiveAccount(makeReport({ metadata: { projectId: "gcp-proj-1" } }), makeLimit(), identity),
).toBe(true);
expect(limitMatchesActiveAccount(makeReport(), makeLimit({ projectId: "gcp-proj-2" }), identity)).toBe(false);
});
test("returns false without an identity or with an empty identity", () => {
expect(limitMatchesActiveAccount(makeReport({ metadata: { email: "a@b.c" } }), makeLimit(), undefined)).toBe(
false,
);
expect(limitMatchesActiveAccount(makeReport({ metadata: { email: "a@b.c" } }), makeLimit(), {})).toBe(false);
});
});
describe("reportMatchesActiveAccount", () => {
test("matches when any limit column belongs to the identity", () => {
const report = makeReport({
limits: [makeLimit({ accountId: "other" }), makeLimit({ accountId: "acc-1" })],
});
expect(reportMatchesActiveAccount(report, { accountId: "acc-1" })).toBe(true);
expect(reportMatchesActiveAccount(report, { accountId: "acc-3" })).toBe(false);
});
test("does not match a report with no limits", () => {
const report = makeReport({ limits: [], metadata: { email: "user@example.com" } });
expect(reportMatchesActiveAccount(report, { email: "user@example.com" })).toBe(false);
});
});